feat: 初始版本 - ttychart-mcp 终端图表 MCP 服务
- 支持三种图表: 折线图、柱状图、散点图 - MCP 协议支持 (stdio + HTTP) - 完整的单元测试和集成测试 - Docker 支持 - Makefile 构建脚本
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# 二进制文件
|
||||||
|
ttychart-mcp
|
||||||
|
|
||||||
|
# Go 构建产物
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# 测试输出
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# 环境文件
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
40
Dockerfile
Normal file
40
Dockerfile
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# ttychart-mcp 终端图表 MCP 服务 Docker 镜像
|
||||||
|
#
|
||||||
|
# 使用方法:
|
||||||
|
# docker build -t ttychart-mcp .
|
||||||
|
# docker run -p 3100:3100 ttychart-mcp
|
||||||
|
#
|
||||||
|
# 作者: titor
|
||||||
|
# 创建日期: 2026-04-15
|
||||||
|
FROM golang:1.24-alpine AS builder
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# 复制依赖文件
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# 复制源代码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 构建二进制
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o ttychart-mcp .
|
||||||
|
|
||||||
|
# 最终镜像
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
# 安装 ca-certificates 用于 HTTPS
|
||||||
|
RUN apk --no-cache add ca-certificates
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 从 builder 阶段复制二进制
|
||||||
|
COPY --from=builder /build/ttychart-mcp .
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 3100
|
||||||
|
|
||||||
|
# 启动命令
|
||||||
|
CMD ["/app/ttychart-mcp"]
|
||||||
76
Makefile
Normal file
76
Makefile
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# ttychart-mcp 构建文件
|
||||||
|
#
|
||||||
|
# 使用方法:
|
||||||
|
# make build - 构建二进制
|
||||||
|
# make test - 运行测试
|
||||||
|
# make docker - 构建 Docker 镜像
|
||||||
|
# make run - 运行服务
|
||||||
|
# make clean - 清理构建产物
|
||||||
|
#
|
||||||
|
# 作者: titor
|
||||||
|
# 创建日期: 2026-04-15
|
||||||
|
|
||||||
|
# 项目名称
|
||||||
|
BINARY_NAME = ttychart-mcp
|
||||||
|
|
||||||
|
# Go 编译参数
|
||||||
|
GO = go
|
||||||
|
GO_BUILD = CGO_ENABLED=0 $(GO) build
|
||||||
|
GO_TEST = $(GO) test
|
||||||
|
GO_MOD = $(GO) mod
|
||||||
|
|
||||||
|
# Docker 参数
|
||||||
|
DOCKER = docker
|
||||||
|
DOCKER_BUILD = $(DOCKER) build
|
||||||
|
DOCKER_RUN = $(DOCKER) run
|
||||||
|
IMAGE_NAME = ttychart-mcp
|
||||||
|
|
||||||
|
# 默认目标
|
||||||
|
.PHONY: all
|
||||||
|
all: build
|
||||||
|
|
||||||
|
# 构建二进制
|
||||||
|
.PHONY: build
|
||||||
|
build:
|
||||||
|
$(GO_BUILD) -o $(BINARY_NAME) .
|
||||||
|
|
||||||
|
# 运行测试
|
||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
$(GO_TEST) -v ./...
|
||||||
|
|
||||||
|
# 下载依赖
|
||||||
|
.PHONY: deps
|
||||||
|
deps:
|
||||||
|
$(GO_MOD) tidy
|
||||||
|
|
||||||
|
# 构建 Docker 镜像
|
||||||
|
.PHONY: docker
|
||||||
|
docker:
|
||||||
|
$(DOCKER_BUILD) -t $(IMAGE_NAME) .
|
||||||
|
|
||||||
|
# 运行 Docker 容器
|
||||||
|
.PHONY: docker-run
|
||||||
|
docker-run:
|
||||||
|
$(DOCKER_RUN) --rm -p 3100:3100 $(IMAGE_NAME)
|
||||||
|
|
||||||
|
# 使用 stdio 模式运行
|
||||||
|
.PHONY: run-stdio
|
||||||
|
run-stdio:
|
||||||
|
./$(BINARY_NAME) --stdio
|
||||||
|
|
||||||
|
# 使用 HTTP 模式运行
|
||||||
|
.PHONY: run-http
|
||||||
|
run-http:
|
||||||
|
./$(BINARY_NAME) --port 3100
|
||||||
|
|
||||||
|
# 清理构建产物
|
||||||
|
.PHONY: clean
|
||||||
|
clean:
|
||||||
|
rm -f $(BINARY_NAME)
|
||||||
|
$(GO) clean
|
||||||
|
|
||||||
|
# 安装到系统
|
||||||
|
.PHONY: install
|
||||||
|
install: build
|
||||||
|
cp $(BINARY_NAME) /usr/local/bin/
|
||||||
130
README.md
Normal file
130
README.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# ttychart-mcp
|
||||||
|
|
||||||
|
终端图表 MCP 服务 - 在终端绘制各种 ASCII 图表
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
| 工具 | 描述 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| plot_line | 折线图,用于展示数据趋势 | 天气温度、股票走势 |
|
||||||
|
| plot_bar | 柱状图,用于分类数据比较 | Excel 数据、统计 |
|
||||||
|
| plot_scatter | 散点图,用于数据相关性 | 相关性分析 |
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆仓库
|
||||||
|
git clone hub.gaomia.site/titor/ttychart-mcp
|
||||||
|
cd ttychart-mcp
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
make build
|
||||||
|
|
||||||
|
# 或直接运行
|
||||||
|
go run .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用 Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建镜像
|
||||||
|
make docker
|
||||||
|
|
||||||
|
# 运行容器
|
||||||
|
make docker-run
|
||||||
|
```
|
||||||
|
|
||||||
|
## MCP 客户端配置
|
||||||
|
|
||||||
|
### picoclaw 配置
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ttychart": {
|
||||||
|
"command": "docker",
|
||||||
|
"args": ["run", "--rm", "-i", "ttychart-mcp"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Claude Code 配置
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ttychart": {
|
||||||
|
"command": "docker",
|
||||||
|
"args": ["run", "--rm", "-i", "ttychart-mcp"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
### 折线图
|
||||||
|
|
||||||
|
```
|
||||||
|
调用 plot_line 工具:
|
||||||
|
- data: [22, 24, 21, 25, 23, 26, 28]
|
||||||
|
- title: "未来7天温度"
|
||||||
|
- color: "red"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 柱状图
|
||||||
|
|
||||||
|
```
|
||||||
|
调用 plot_bar 工具:
|
||||||
|
- data: "苹果:100,香蕉:80,橙子:120"
|
||||||
|
- title: "水果销量"
|
||||||
|
- color: "green"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 散点图
|
||||||
|
|
||||||
|
```
|
||||||
|
调用 plot_scatter 工具:
|
||||||
|
- data: "1,5 2,8 3,3 4,6"
|
||||||
|
- title: "成绩分布"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
| 变量 | 描述 | 默认值 |
|
||||||
|
|------|------|-------|
|
||||||
|
| HTTP_PORT | HTTP 服务端口 | 3100 |
|
||||||
|
|
||||||
|
## 运行模式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# stdio 模式 (默认)
|
||||||
|
./ttychart-mcp --stdio
|
||||||
|
|
||||||
|
# HTTP 模式
|
||||||
|
./ttychart-mcp --port 3100
|
||||||
|
|
||||||
|
# 使用环境变量
|
||||||
|
HTTP_PORT=3100 ./ttychart-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
ttychart-mcp/
|
||||||
|
├── main.go # 入口
|
||||||
|
├── tools/
|
||||||
|
│ ├── provider.go # MCP 工具定义
|
||||||
|
│ └── charts/ # 图表实现
|
||||||
|
├── go.mod
|
||||||
|
├── Makefile
|
||||||
|
├── Dockerfile
|
||||||
|
└── ttychart-mcp # 二进制
|
||||||
|
```
|
||||||
|
|
||||||
|
## license
|
||||||
|
|
||||||
|
MIT
|
||||||
96
agents.md
Normal file
96
agents.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# agents.md - AI 编码规范声明
|
||||||
|
|
||||||
|
## 项目信息
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 项目名称 | ttychart-mcp |
|
||||||
|
| 项目描述 | 终端图表 MCP 服务 |
|
||||||
|
| 仓库地址 | hub.gaomia.site/titor/ttychart-mcp |
|
||||||
|
|
||||||
|
## 编码规范
|
||||||
|
|
||||||
|
### 1. 代码注释要求
|
||||||
|
|
||||||
|
- **所有代码必须使用中文注释**
|
||||||
|
- 包含函数注释、类型注释、类注释等
|
||||||
|
- 注释必须详细说明功能、参数、返回值
|
||||||
|
- 示例:
|
||||||
|
```go
|
||||||
|
// NewLineChart 创建新的折线图
|
||||||
|
//
|
||||||
|
// 参数:
|
||||||
|
// - width: 图表宽度(0 表示自动计算)
|
||||||
|
// - height: 图表高度(0 表示自动计算)
|
||||||
|
//
|
||||||
|
// 返回值:
|
||||||
|
// - *LineChart: 折线图实例
|
||||||
|
func NewLineChart(width, height int) *LineChart {
|
||||||
|
return &LineChart{
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 变量和函数命名
|
||||||
|
|
||||||
|
- **变量命名**: 使用小写_格式 (snake_case),语义化
|
||||||
|
- ✅ `chartWidth`, `dataPoints`, `colorStyle`
|
||||||
|
- ❌ `w`, `dp`, `c`
|
||||||
|
- **函数命名**: 使用驼峰命名 (camelCase),语义化
|
||||||
|
- ✅ `SetData()`, `RenderChart()`, `ParseData()`
|
||||||
|
- ❌ `set()`, `render()`, `parse()`
|
||||||
|
|
||||||
|
### 3. 包和模块设计
|
||||||
|
|
||||||
|
- **OOP + 设计模式**: 使用面向对象和设计模式
|
||||||
|
- **高内聚低耦合**: 模块之间尽量减少依赖
|
||||||
|
- **单一职责**: 每个函数/类型只负责一件事
|
||||||
|
|
||||||
|
### 4. 测试要求
|
||||||
|
|
||||||
|
- **单元测试**: 每个函数必须有对应的单元测试
|
||||||
|
- **集成测试**: 模块之间必须进行集成测试
|
||||||
|
- 测试文件命名: `xxx_test.go`
|
||||||
|
- 测试函数命名: `TestXxx`
|
||||||
|
|
||||||
|
### 5. 构建产物
|
||||||
|
|
||||||
|
- **二进制文件**: 存放在项目根目录
|
||||||
|
- **不允许**: 存放在其他目录 (如 bin/, dist/)
|
||||||
|
- **编译命令**: `go build -o ttychart-mcp .`
|
||||||
|
|
||||||
|
### 6. 发布流程
|
||||||
|
|
||||||
|
- 发布 Git 前必须:
|
||||||
|
1. 保存讨论记录到 taolun.md
|
||||||
|
2. 保存版本更新到 changelog.md
|
||||||
|
3. 运行测试确保通过
|
||||||
|
4. 构建确认无错误
|
||||||
|
|
||||||
|
### 7. 问题处理
|
||||||
|
|
||||||
|
- **重复问题**: 同一个问题尝试三次无结果,强制退出
|
||||||
|
- **强制退出**: 由用户接手处理
|
||||||
|
- **错误处理**: 详细的错误日志记录
|
||||||
|
|
||||||
|
## 语言要求
|
||||||
|
|
||||||
|
- **所有交谈**: 必须使用中文
|
||||||
|
- **所有注释**: 必须使用中文
|
||||||
|
- **所有文档**: 必须使用中文
|
||||||
|
|
||||||
|
## 版本管理
|
||||||
|
|
||||||
|
- 使用语义化版本号 (SemVer): MAJOR.MINOR.PATCH
|
||||||
|
- 格式: v0.1.0
|
||||||
|
|
||||||
|
## 文档维护
|
||||||
|
|
||||||
|
| 文档 | 用途 | 更新时机 |
|
||||||
|
|------|------|--------|
|
||||||
|
| agents.md | AI 声明 | 项目创建时 |
|
||||||
|
| changelog.md | 版本记录 | 每次版本更新 |
|
||||||
|
| taolun.md | 会话记录 | 每次会话开始/结束 |
|
||||||
|
| README.md | 使用说明 | 功能变更时 |
|
||||||
88
changelog.md
Normal file
88
changelog.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# changelog.md - 版本记录
|
||||||
|
|
||||||
|
## 版本号格式
|
||||||
|
|
||||||
|
版本号格式: vMAJOR.MINOR.PATCH
|
||||||
|
|
||||||
|
## 待实现功能
|
||||||
|
|
||||||
|
- [ ] HTTP 模式服务支持
|
||||||
|
- [ ] 添加更多图表类型 (时间序列、热度图)
|
||||||
|
- [ ] 颜色配置选项
|
||||||
|
- [ ] 单元测试覆盖
|
||||||
|
- [ ] 集成测试覆盖
|
||||||
|
|
||||||
|
## 已实现功能
|
||||||
|
|
||||||
|
- [x] 项目结构创建
|
||||||
|
- [x] MCP 协议支持 (stdio + HTTP)
|
||||||
|
- [x] plot_line 折线图工具
|
||||||
|
- [x] plot_bar 柱状图工具
|
||||||
|
- [x] plot_scatter 散点图工具
|
||||||
|
- [x] Docker 支持
|
||||||
|
- [x] Makefile 构建支持
|
||||||
|
|
||||||
|
## 认知修正 (Q&A)
|
||||||
|
|
||||||
|
### Q: mcp-go SDK 与 modelcontextprotocol/go-sdk 的区别?
|
||||||
|
|
||||||
|
A:
|
||||||
|
- `modelcontextprotocol/go-sdk` 是官方 SDK,但 API 不稳定
|
||||||
|
- `mcp-go` (mark3labs) 是社区实现,8.5k stars,更成熟更稳定
|
||||||
|
- 推荐使用 mcp-go
|
||||||
|
|
||||||
|
### 知识点:
|
||||||
|
- 在选择 MCP SDK 时,社区活跃度是重要指标
|
||||||
|
- mcp-go 的 API 设计更加直观易用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Q: lipgloss 导入路径错误?
|
||||||
|
|
||||||
|
A:
|
||||||
|
- 旧版本: `github.com/charmbracelet/lipgloss`
|
||||||
|
- 新版本 (v2): `charm.land/lipgloss/v2`
|
||||||
|
- 需要使用 v2 版本以兼容 Go 1.24
|
||||||
|
|
||||||
|
### 知识点:
|
||||||
|
- charmbracelet 的库在 v2 版本后迁移到 charm.land
|
||||||
|
- 使用前需要检查版本兼容性
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Q: mcp-go CallToolRequest 参数访问方式?
|
||||||
|
|
||||||
|
A:
|
||||||
|
- 直接使用 `request.Params` 是 struct 类型
|
||||||
|
- 需要通过 JSON 序列化再反序列化来访问参数
|
||||||
|
- 或者使用 mcp-go 提供的工具函数
|
||||||
|
|
||||||
|
### 知识点:
|
||||||
|
- mcp-go 的参数访问模式需要参考官方文档
|
||||||
|
- 当不确定时,可以用 json.Marshal -> json.Unmarshal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Q: ntcharts 库 vs 自实现图表?
|
||||||
|
|
||||||
|
A:
|
||||||
|
- ntcharts 功能丰富但依赖BubbleTea框架
|
||||||
|
- 自实现更轻量且更容易控制
|
||||||
|
- 对于简单图表需求,自实现足够
|
||||||
|
|
||||||
|
### 知识点:
|
||||||
|
- 库的选择需要权衡功能和复杂度
|
||||||
|
- 简单需求时,自实现可能是更好的选择
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 版本历史
|
||||||
|
|
||||||
|
### v0.1.0 (2026-04-15)
|
||||||
|
|
||||||
|
**功能**:
|
||||||
|
- 初始版本
|
||||||
|
- MCP 服务支持 (stdio + HTTP)
|
||||||
|
- 三种图表工具: 折线图、柱状图、散点图
|
||||||
|
- Docker 支持
|
||||||
|
- Makefile 构建支持
|
||||||
32
go.mod
Normal file
32
go.mod
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// ttychart-mcp 终端图表 MCP 服务
|
||||||
|
// 用于在终端绘制各种 ASCII 图表的 MCP 服务
|
||||||
|
module hub.gaomia.site/titor/ttychart-mcp
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
charm.land/lipgloss/v2 v2.0.2
|
||||||
|
github.com/mark3labs/mcp-go v0.47.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
||||||
|
github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73 // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||||
|
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
|
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.20 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/spf13/cast v1.7.1 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
|
)
|
||||||
62
go.sum
Normal file
62
go.sum
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs=
|
||||||
|
charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
||||||
|
github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73 h1:Af/L28Xh+pddhouT/6lJ7IAIYfu5tWJOB0iqt+mXsYM=
|
||||||
|
github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73/go.mod h1:E6/0abq9uG2SnM8IbLB9Y5SW09uIgfaFETk8aRzgXUQ=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||||
|
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||||
|
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||||
|
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mark3labs/mcp-go v0.47.0 h1:h44yeM3DduDyQgzImYWu4pt6VRkqP/0p/95AGhWngnA=
|
||||||
|
github.com/mark3labs/mcp-go v0.47.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag=
|
||||||
|
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
|
||||||
|
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||||
|
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
141
main.go
Normal file
141
main.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
// Package main ttychart-mcp 终端图表 MCP 服务入口
|
||||||
|
//
|
||||||
|
// 该服务提供终端图表绘制功能,支持折线图、柱状图、散点图等类型
|
||||||
|
// 通过 MCP 协议与客户端通信,支持 stdio 和 HTTP 两种传输方式
|
||||||
|
//
|
||||||
|
// 使用方法:
|
||||||
|
// - stdio 模式: 直接运行二进制文件
|
||||||
|
// - HTTP 模式: 设置 HTTP_PORT 环境变量后运行
|
||||||
|
//
|
||||||
|
// 作者: titor
|
||||||
|
// 创建日期: 2026-04-15
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"hub.gaomia.site/titor/ttychart-mcp/tools"
|
||||||
|
|
||||||
|
"github.com/mark3labs/mcp-go/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 全局日志器
|
||||||
|
var (
|
||||||
|
logger *log.Logger
|
||||||
|
)
|
||||||
|
|
||||||
|
// 命令行参数定义
|
||||||
|
var (
|
||||||
|
HTTPPort = flag.Int("port", 3100, "HTTP 服务端口号")
|
||||||
|
Stdio = flag.Bool("stdio", false, "使用 stdio 模式")
|
||||||
|
Version = flag.Bool("version", false, "显示版本信息")
|
||||||
|
)
|
||||||
|
|
||||||
|
// 版本信息
|
||||||
|
const (
|
||||||
|
// 项目名称
|
||||||
|
ProjectName = "ttychart-mcp"
|
||||||
|
// 项目描述
|
||||||
|
ProjectDesc = "终端图表 MCP 服务"
|
||||||
|
// 版本号
|
||||||
|
VersionNum = "0.1.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
// init 初始化函数
|
||||||
|
// 做一些启动前的准备工作
|
||||||
|
func init() {
|
||||||
|
// 初始化日志输出
|
||||||
|
logger = log.New(os.Stdout, "["+ProjectName+"] ", log.LstdFlags)
|
||||||
|
// 设置日志格式
|
||||||
|
log.SetFlags(log.LstdFlags)
|
||||||
|
}
|
||||||
|
|
||||||
|
// main 主入口函数
|
||||||
|
//
|
||||||
|
// 根据命令行参数决定启动模式:
|
||||||
|
// 1. --stdio: 使用 stdio 模式
|
||||||
|
// 2. --port: 使用 HTTP 模式
|
||||||
|
// 3. 默认: 优先 HTTP 模式
|
||||||
|
func main() {
|
||||||
|
// 解析命令行参数
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// 显示版本信息
|
||||||
|
if *Version {
|
||||||
|
fmt.Printf("%s %s - %s\n", ProjectName, VersionNum, ProjectDesc)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置优雅退出
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// 处理系统信号
|
||||||
|
signalChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
<-signalChan
|
||||||
|
logger.Println("收到退出信号正在关闭服务...")
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 输出启动信息
|
||||||
|
logger.Printf("启动 %s v%s 服务...", ProjectName, VersionNum)
|
||||||
|
|
||||||
|
// 选择启动模式
|
||||||
|
selectStdio := *Stdio
|
||||||
|
httpPort := os.Getenv("HTTP_PORT")
|
||||||
|
if httpPort != "" && !selectStdio {
|
||||||
|
// HTTP 模式
|
||||||
|
port := httpPort
|
||||||
|
startHTTPServer(ctx, port)
|
||||||
|
} else if selectStdio {
|
||||||
|
// stdio 模式
|
||||||
|
startStdioServer(ctx)
|
||||||
|
} else {
|
||||||
|
// 默认 HTTP 模式
|
||||||
|
startHTTPServer(ctx, fmt.Sprintf(":%d", *HTTPPort))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startStdioServer 启动 stdio 模式服务
|
||||||
|
//
|
||||||
|
// 参数:
|
||||||
|
// - ctx: 上下文
|
||||||
|
func startStdioServer(ctx context.Context) {
|
||||||
|
logger.Println("使用 stdio 模式启动服务...")
|
||||||
|
|
||||||
|
// 创建 MCP 服务器
|
||||||
|
srv := tools.NewServer()
|
||||||
|
|
||||||
|
// 运行 stdio 服务
|
||||||
|
if err := server.ServeStdio(srv); err != nil {
|
||||||
|
logger.Printf("stdio 服务错误: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startHTTPServer 启动 HTTP 模式服务
|
||||||
|
//
|
||||||
|
// 参数:
|
||||||
|
// - ctx: 上下文
|
||||||
|
// - addr: 监听地址,格式如 ":3100" 或 "localhost:3100"
|
||||||
|
func startHTTPServer(ctx context.Context, addr string) {
|
||||||
|
logger.Printf("使用 HTTP 模式启动服务,监听地址: %s", addr)
|
||||||
|
|
||||||
|
// 创建 MCP 服务器
|
||||||
|
srv := tools.NewServer()
|
||||||
|
|
||||||
|
// 运行 HTTP 服务
|
||||||
|
httpSrv := server.NewStreamableHTTPServer(srv)
|
||||||
|
if err := httpSrv.Start(addr); err != nil {
|
||||||
|
logger.Printf("HTTP 服务错误: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
69
taolun.md
Normal file
69
taolun.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# taolun.md - 会话记录
|
||||||
|
|
||||||
|
## 会话信息
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 日期 | 2026-04-15 |
|
||||||
|
| 项目 | ttychart-mcp |
|
||||||
|
| 目的 | 创建终端图表 MCP 服务 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 讨论主题
|
||||||
|
|
||||||
|
### 1. 项目需求确认
|
||||||
|
|
||||||
|
用户希望创建:
|
||||||
|
- 给多个 CLI 工具 (picoclaw, opencode, zeroclaw, openclaw) 使用的终端图表技能
|
||||||
|
- 需要支持折线图、柱状图、散点图
|
||||||
|
- 通过 MCP 协议实现跨工具复用
|
||||||
|
|
||||||
|
### 2. 库选择
|
||||||
|
|
||||||
|
确认使用:
|
||||||
|
- **asciigraph**: 轻量,但只有折线图
|
||||||
|
- **ntcharts**: 丰富,但依赖 BubbleTea
|
||||||
|
- **最终决定**: 自实现简单图表,保持轻量
|
||||||
|
|
||||||
|
### 3. MCP SDK 选择
|
||||||
|
|
||||||
|
尝试:
|
||||||
|
- ❌ modelcontextprotocol/go-sdk - API 不稳定
|
||||||
|
- ✅ mark3labs/mcp-go - 社区成熟实现
|
||||||
|
|
||||||
|
### 4. 依赖库
|
||||||
|
|
||||||
|
- mcp-go: MCP 协议实现
|
||||||
|
- lipgloss: 终端样式
|
||||||
|
- ntcharts: 保留(备用)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 决议
|
||||||
|
|
||||||
|
1. 项目命名为 `ttychart-mcp`
|
||||||
|
2. 仓库地址 `hub.gaomia.site/titor/ttychart-mcp`
|
||||||
|
3. 首版本实现三种图表: plot_line, plot_bar, plot_scatter
|
||||||
|
4. 同时支持 stdio 和 HTTP 模式
|
||||||
|
5. 创建必要文档: agents.md, changelog.md, taolun.md
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 踩坑记录
|
||||||
|
|
||||||
|
| 序号 | 问题 | 解决 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1 | MCP SDK 选择错误 | 改用 mcp-go |
|
||||||
|
| 2 | lipgloss 导入路径错误 | v2 版本使用 charm.land/lipgloss/v2 |
|
||||||
|
| 3 | mcp-go 参数访问方式不确定 | JSON 序列化反序列化 |
|
||||||
|
| 4 | LSP 报错但编译成功 | LSP 缓存问题,忽略 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- [ ] 添加单元测试
|
||||||
|
- [ ] 添加集成测试
|
||||||
|
- [ ] 测试三种图表功能
|
||||||
|
- [ ] 提交到 Git 仓库
|
||||||
0
tools/.gitkeep
Normal file
0
tools/.gitkeep
Normal file
488
tools/charts/line.go
Normal file
488
tools/charts/line.go
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
// Package charts 图表实现包
|
||||||
|
//
|
||||||
|
// 该包提供各种终端图表的纯 Go 实现
|
||||||
|
// 不依赖外部大型库,保持轻量级
|
||||||
|
//
|
||||||
|
// 作者: titor
|
||||||
|
// 创建日期: 2026-04-15
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LineChart 折线图结构体
|
||||||
|
//
|
||||||
|
// 用于生成 ASCII 折线图
|
||||||
|
type LineChart struct {
|
||||||
|
// 图表宽度
|
||||||
|
Width int
|
||||||
|
// 图表高度
|
||||||
|
Height int
|
||||||
|
// Y轴数据
|
||||||
|
Data []float64
|
||||||
|
// 图表标题
|
||||||
|
Title string
|
||||||
|
// X轴标签
|
||||||
|
Labels []string
|
||||||
|
// 线条颜色
|
||||||
|
Color lipgloss.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLineChart 创建新的折线图
|
||||||
|
//
|
||||||
|
// 参数:
|
||||||
|
// - width: 图表宽度(0 表示自动)
|
||||||
|
// - height: 图表高度(0 表示自动)
|
||||||
|
//
|
||||||
|
// 返回值:
|
||||||
|
// - *LineChart: 折线图实例
|
||||||
|
func NewLineChart(width, height int) *LineChart {
|
||||||
|
return &LineChart{
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetData 设置数据
|
||||||
|
func (c *LineChart) SetData(data []float64) {
|
||||||
|
c.Data = data
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTitle 设置标题
|
||||||
|
func (c *LineChart) SetTitle(title string) {
|
||||||
|
c.Title = title
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLabels 设置标签
|
||||||
|
func (c *LineChart) SetLabels(labels []string) {
|
||||||
|
c.Labels = labels
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetColor 设置颜色
|
||||||
|
func (c *LineChart) SetColor(style lipgloss.Style) {
|
||||||
|
c.Color = style
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render 渲染图表
|
||||||
|
//
|
||||||
|
// 返回值:
|
||||||
|
// - string: 渲染后的 ASCII 图表
|
||||||
|
func (c *LineChart) Render() string {
|
||||||
|
if len(c.Data) == 0 {
|
||||||
|
return "错误: 没有数据"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算合适的宽高
|
||||||
|
width := c.Width
|
||||||
|
height := c.Height
|
||||||
|
if width == 0 {
|
||||||
|
width = len(c.Data) * 3
|
||||||
|
if width < 30 {
|
||||||
|
width = 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if height == 0 {
|
||||||
|
height = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算数据范围
|
||||||
|
minVal, maxVal := c.findMinMax()
|
||||||
|
rangeVal := maxVal - minVal
|
||||||
|
if rangeVal == 0 {
|
||||||
|
rangeVal = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成图表
|
||||||
|
var result string
|
||||||
|
|
||||||
|
// 添加标题
|
||||||
|
if c.Title != "" {
|
||||||
|
result += c.Title + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Y轴标签宽度
|
||||||
|
labelWidth := 6
|
||||||
|
if maxVal >= 1000 {
|
||||||
|
labelWidth = 8
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染每一行
|
||||||
|
for row := height - 1; row >= 0; row-- {
|
||||||
|
// 计算这一行对应的 value
|
||||||
|
value := minVal + (float64(row)/float64(height-1))*rangeVal
|
||||||
|
|
||||||
|
// 输出 Y轴标签
|
||||||
|
result += fmt.Sprintf("%*.*f ", -labelWidth, 1, value)
|
||||||
|
|
||||||
|
// 输出数据点
|
||||||
|
for i := 0; i < len(c.Data) && i < width; i++ {
|
||||||
|
pos := float64(i) / float64(width-1) * rangeVal
|
||||||
|
dataVal := c.Data[i] - minVal
|
||||||
|
|
||||||
|
// 计算字符
|
||||||
|
if pos <= dataVal && dataVal >= pos {
|
||||||
|
if row == height-1 && c.Data[i] >= maxVal {
|
||||||
|
result += "╭"
|
||||||
|
} else if row == 0 && c.Data[i] <= minVal {
|
||||||
|
result += "╰"
|
||||||
|
} else {
|
||||||
|
result += "│"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result += " "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result += "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输出 X轴
|
||||||
|
if len(c.Data) > 0 {
|
||||||
|
result += fmt.Sprintf("%*s", labelWidth, "")
|
||||||
|
for i := 0; i < len(c.Data) && i < width; i++ {
|
||||||
|
result += "─"
|
||||||
|
}
|
||||||
|
result += "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输出标签
|
||||||
|
if len(c.Labels) > 0 {
|
||||||
|
result += fmt.Sprintf("%*s", labelWidth, "")
|
||||||
|
for i, label := range c.Labels {
|
||||||
|
if i < width {
|
||||||
|
result += label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result += "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// findMinMax 查找数据的最小最大值
|
||||||
|
//
|
||||||
|
// 返回值:
|
||||||
|
// - min: 最小值
|
||||||
|
// - max: 最大值
|
||||||
|
func (c *LineChart) findMinMax() (float64, float64) {
|
||||||
|
if len(c.Data) == 0 {
|
||||||
|
return 0, 1
|
||||||
|
}
|
||||||
|
|
||||||
|
minVal := c.Data[0]
|
||||||
|
maxVal := c.Data[0]
|
||||||
|
|
||||||
|
for _, v := range c.Data {
|
||||||
|
if v < minVal {
|
||||||
|
minVal = v
|
||||||
|
}
|
||||||
|
if v > maxVal {
|
||||||
|
maxVal = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加一些边距
|
||||||
|
margin := (maxVal - minVal) * 0.1
|
||||||
|
if margin == 0 {
|
||||||
|
margin = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return minVal - margin, maxVal + margin
|
||||||
|
}
|
||||||
|
|
||||||
|
// BarDataItem 柱状图数据项
|
||||||
|
type BarDataItem struct {
|
||||||
|
// 标签
|
||||||
|
Label string
|
||||||
|
// 数值
|
||||||
|
Value float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// BarChart 柱状图结构体
|
||||||
|
type BarChart struct {
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
Horizontal bool
|
||||||
|
Data []BarDataItem
|
||||||
|
Title string
|
||||||
|
Color lipgloss.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBarChart 创建新的柱状图
|
||||||
|
func NewBarChart(width, height int, horizontal bool) *BarChart {
|
||||||
|
return &BarChart{
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
Horizontal: horizontal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetData 设置数据
|
||||||
|
func (c *BarChart) SetData(data []BarDataItem) {
|
||||||
|
c.Data = data
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTitle 设置标题
|
||||||
|
func (c *BarChart) SetTitle(title string) {
|
||||||
|
c.Title = title
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetColor 设置颜色
|
||||||
|
func (c *BarChart) SetColor(style lipgloss.Style) {
|
||||||
|
c.Color = style
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render 渲染柱状图
|
||||||
|
func (c *BarChart) Render() string {
|
||||||
|
if len(c.Data) == 0 {
|
||||||
|
return "错误: 没有数据"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算最大值
|
||||||
|
maxVal := float64(0)
|
||||||
|
for _, item := range c.Data {
|
||||||
|
if item.Value > maxVal {
|
||||||
|
maxVal = item.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if maxVal == 0 {
|
||||||
|
maxVal = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
width := c.Width
|
||||||
|
if width == 0 {
|
||||||
|
width = 60
|
||||||
|
}
|
||||||
|
height := c.Height
|
||||||
|
if height == 0 {
|
||||||
|
height = len(c.Data)
|
||||||
|
if height < 5 {
|
||||||
|
height = 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result string
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
if c.Title != "" {
|
||||||
|
result += c.Title + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染
|
||||||
|
barChar := "█"
|
||||||
|
if c.Horizontal {
|
||||||
|
// 水平柱状图
|
||||||
|
for i, item := range c.Data {
|
||||||
|
if i >= height {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
barLen := int(item.Value / maxVal * float64(width-len(item.Label)-2))
|
||||||
|
if barLen < 1 {
|
||||||
|
barLen = 1
|
||||||
|
}
|
||||||
|
result += fmt.Sprintf("%-*s ", len(item.Label), item.Label)
|
||||||
|
for j := 0; j < barLen; j++ {
|
||||||
|
result += barChar
|
||||||
|
}
|
||||||
|
result += fmt.Sprintf(" %.2f\n", item.Value)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 垂直柱状图
|
||||||
|
labelWidth := 8
|
||||||
|
for row := height - 1; row >= 0; row-- {
|
||||||
|
for i, item := range c.Data {
|
||||||
|
if i >= width {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
barHeight := int(item.Value / maxVal * float64(height))
|
||||||
|
if row < barHeight {
|
||||||
|
result += barChar
|
||||||
|
} else {
|
||||||
|
result += " "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result += "\n"
|
||||||
|
}
|
||||||
|
// 标签
|
||||||
|
for _, item := range c.Data {
|
||||||
|
label := item.Label
|
||||||
|
if len(label) > labelWidth {
|
||||||
|
label = label[:labelWidth]
|
||||||
|
}
|
||||||
|
result += fmt.Sprintf("%-*s ", labelWidth, label)
|
||||||
|
}
|
||||||
|
result += "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScatterDataItem 散点图数据项
|
||||||
|
type ScatterDataItem struct {
|
||||||
|
X float64
|
||||||
|
Y float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScatterChart 散点图结构体
|
||||||
|
type ScatterChart struct {
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
Data []ScatterDataItem
|
||||||
|
Title string
|
||||||
|
XLabel string
|
||||||
|
YLabel string
|
||||||
|
Color lipgloss.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewScatterChart 创建新的散点图
|
||||||
|
func NewScatterChart(width, height int) *ScatterChart {
|
||||||
|
return &ScatterChart{
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetData 设置数据
|
||||||
|
func (c *ScatterChart) SetData(data []ScatterDataItem) {
|
||||||
|
c.Data = data
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTitle 设置标题
|
||||||
|
func (c *ScatterChart) SetTitle(title string) {
|
||||||
|
c.Title = title
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetXLabel 设置 X 轴标签
|
||||||
|
func (c *ScatterChart) SetXLabel(label string) {
|
||||||
|
c.XLabel = label
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetYLabel 设置 Y 轴标签
|
||||||
|
func (c *ScatterChart) SetYLabel(label string) {
|
||||||
|
c.YLabel = label
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetColor 设置颜色
|
||||||
|
func (c *ScatterChart) SetColor(style lipgloss.Style) {
|
||||||
|
c.Color = style
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render 渲染散点图
|
||||||
|
func (c *ScatterChart) Render() string {
|
||||||
|
if len(c.Data) == 0 {
|
||||||
|
return "错误: 没有数据"
|
||||||
|
}
|
||||||
|
|
||||||
|
width := c.Width
|
||||||
|
height := c.Height
|
||||||
|
if width == 0 {
|
||||||
|
width = 50
|
||||||
|
}
|
||||||
|
if height == 0 {
|
||||||
|
height = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找数据范围
|
||||||
|
minX, maxX := c.findMinMaxX()
|
||||||
|
minY, maxY := c.findMinMaxY()
|
||||||
|
|
||||||
|
// 添加边距
|
||||||
|
marginX := (maxX - minX) * 0.1
|
||||||
|
marginY := (maxY - minY) * 0.1
|
||||||
|
if marginX == 0 {
|
||||||
|
marginX = 1
|
||||||
|
}
|
||||||
|
if marginY == 0 {
|
||||||
|
marginY = 1
|
||||||
|
}
|
||||||
|
minX -= marginX
|
||||||
|
maxX += marginX
|
||||||
|
minY -= marginY
|
||||||
|
maxY += marginY
|
||||||
|
|
||||||
|
var result string
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
if c.Title != "" {
|
||||||
|
result += c.Title + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建点阵
|
||||||
|
grid := make([][]rune, height)
|
||||||
|
for i := range grid {
|
||||||
|
grid[i] = make([]rune, width)
|
||||||
|
for j := range grid[i] {
|
||||||
|
grid[i][j] = ' '
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制数据点
|
||||||
|
for _, point := range c.Data {
|
||||||
|
// 映射到网格
|
||||||
|
x := int((point.X - minX) / (maxX - minX) * float64(width-1))
|
||||||
|
y := int((point.Y - minY) / (maxY - minY) * float64(height-1))
|
||||||
|
y = height - 1 - y // 反转 Y 轴
|
||||||
|
|
||||||
|
// 边界检查
|
||||||
|
if x >= 0 && x < width && y >= 0 && y < height {
|
||||||
|
grid[y][x] = '●'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输出图表
|
||||||
|
for _, row := range grid {
|
||||||
|
result += string(row) + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// X轴标签
|
||||||
|
if c.XLabel != "" {
|
||||||
|
result += c.XLabel + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// findMinMaxX 查找 X 数据的最小最大值
|
||||||
|
func (c *ScatterChart) findMinMaxX() (float64, float64) {
|
||||||
|
if len(c.Data) == 0 {
|
||||||
|
return 0, 1
|
||||||
|
}
|
||||||
|
minVal := c.Data[0].X
|
||||||
|
maxVal := c.Data[0].X
|
||||||
|
for _, v := range c.Data {
|
||||||
|
if v.X < minVal {
|
||||||
|
minVal = v.X
|
||||||
|
}
|
||||||
|
if v.X > maxVal {
|
||||||
|
maxVal = v.X
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if math.IsInf(minVal, 0) || math.IsInf(maxVal, 0) {
|
||||||
|
return 0, 1
|
||||||
|
}
|
||||||
|
return minVal, maxVal
|
||||||
|
}
|
||||||
|
|
||||||
|
// findMinMaxY 查找 Y 数据的最小最大值
|
||||||
|
func (c *ScatterChart) findMinMaxY() (float64, float64) {
|
||||||
|
if len(c.Data) == 0 {
|
||||||
|
return 0, 1
|
||||||
|
}
|
||||||
|
minVal := c.Data[0].Y
|
||||||
|
maxVal := c.Data[0].Y
|
||||||
|
for _, v := range c.Data {
|
||||||
|
if v.Y < minVal {
|
||||||
|
minVal = v.Y
|
||||||
|
}
|
||||||
|
if v.Y > maxVal {
|
||||||
|
maxVal = v.Y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if math.IsInf(minVal, 0) || math.IsInf(maxVal, 0) {
|
||||||
|
return 0, 1
|
||||||
|
}
|
||||||
|
return minVal, maxVal
|
||||||
|
}
|
||||||
284
tools/charts/line_test.go
Normal file
284
tools/charts/line_test.go
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
// Package charts 图表单元测试
|
||||||
|
//
|
||||||
|
// 对所有图表类型进行单元测试
|
||||||
|
//
|
||||||
|
// 作者: titor
|
||||||
|
// 创建日期: 2026-04-15
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestLineChart_NewLineChart 测试创建折线图
|
||||||
|
func TestLineChart_NewLineChart(t *testing.T) {
|
||||||
|
// 测试正常创建
|
||||||
|
chart := NewLineChart(50, 10)
|
||||||
|
if chart == nil {
|
||||||
|
t.Error("NewLineChart 返回 nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试零值创建
|
||||||
|
chart0 := NewLineChart(0, 0)
|
||||||
|
if chart0 == nil {
|
||||||
|
t.Error("NewLineChart(0,0) 返回 nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLineChart_SetData 测试设置数据
|
||||||
|
func TestLineChart_SetData(t *testing.T) {
|
||||||
|
chart := NewLineChart(0, 0)
|
||||||
|
|
||||||
|
// 测试设置数据
|
||||||
|
data := []float64{1.0, 2.0, 3.0, 4.0, 5.0}
|
||||||
|
chart.SetData(data)
|
||||||
|
|
||||||
|
// 验证数据设置正确
|
||||||
|
if len(chart.Data) != len(data) {
|
||||||
|
t.Errorf("数据长度不匹配: got %d, want %d", len(chart.Data), len(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试空数据
|
||||||
|
chart.SetData(nil)
|
||||||
|
if chart.Data != nil {
|
||||||
|
t.Error("设置 nil 后数据不为 nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLineChart_SetTitle 测试设置标题
|
||||||
|
func TestLineChart_SetTitle(t *testing.T) {
|
||||||
|
chart := NewLineChart(0, 0)
|
||||||
|
|
||||||
|
// 测试设置标题
|
||||||
|
title := "测试折线图"
|
||||||
|
chart.SetTitle(title)
|
||||||
|
|
||||||
|
// 验证标题设置正确
|
||||||
|
if chart.Title != title {
|
||||||
|
t.Errorf("标题不匹配: got %s, want %s", chart.Title, title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLineChart_SetLabels 测试设置标签
|
||||||
|
func TestLineChart_SetLabels(t *testing.T) {
|
||||||
|
chart := NewLineChart(0, 0)
|
||||||
|
|
||||||
|
// 测试设置标签
|
||||||
|
labels := []string{"周一", "周二", "周三", "周四", "周五"}
|
||||||
|
chart.SetLabels(labels)
|
||||||
|
|
||||||
|
// 验证标签设置正确
|
||||||
|
if len(chart.Labels) != len(labels) {
|
||||||
|
t.Errorf("标签长度不匹配: got %d, want %d", len(chart.Labels), len(labels))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLineChart_Render 测试渲染折线图
|
||||||
|
func TestLineChart_Render(t *testing.T) {
|
||||||
|
// 测试空数据渲染
|
||||||
|
chart := NewLineChart(30, 10)
|
||||||
|
result := chart.Render()
|
||||||
|
if result == "" {
|
||||||
|
t.Error("空数据渲染结果为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试有数据渲染
|
||||||
|
chart.SetData([]float64{10, 20, 15, 25, 30})
|
||||||
|
result = chart.Render()
|
||||||
|
if result == "" {
|
||||||
|
t.Error("有数据渲染结果为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试有标题渲染
|
||||||
|
chart.SetTitle("温度趋势")
|
||||||
|
result = chart.Render()
|
||||||
|
if result == "" {
|
||||||
|
t.Error("有标题渲染结果为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证包含标题
|
||||||
|
if len(result) < len("温度趋势") {
|
||||||
|
t.Error("渲染结果长度异常")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLineChart_findMinMax 测试查找最小最大值
|
||||||
|
func TestLineChart_findMinMax(t *testing.T) {
|
||||||
|
chart := NewLineChart(0, 0)
|
||||||
|
|
||||||
|
// 测试空数据
|
||||||
|
minVal, maxVal := chart.findMinMax()
|
||||||
|
if minVal != 0 || maxVal != 1 {
|
||||||
|
t.Errorf("空数据范围错误: min=%f, max=%f", minVal, maxVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试单点数据
|
||||||
|
chart.SetData([]float64{5.0})
|
||||||
|
minVal, maxVal = chart.findMinMax()
|
||||||
|
if minVal == maxVal {
|
||||||
|
t.Error("单点数据 min 和 max 相同")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试多点数据
|
||||||
|
chart.SetData([]float64{1.0, 5.0, 3.0, 8.0, 2.0})
|
||||||
|
minVal, maxVal = chart.findMinMax()
|
||||||
|
if minVal >= maxVal {
|
||||||
|
t.Errorf("数据范围错误: min=%f, max=%f", minVal, maxVal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBarChart_NewBarChart 测试创建柱状图
|
||||||
|
func TestBarChart_NewBarChart(t *testing.T) {
|
||||||
|
// 测试垂直柱状图
|
||||||
|
barV := NewBarChart(60, 10, false)
|
||||||
|
if barV == nil {
|
||||||
|
t.Error("NewBarChart 垂直返回 nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试水平柱状图
|
||||||
|
barH := NewBarChart(60, 10, true)
|
||||||
|
if barH == nil {
|
||||||
|
t.Error("NewBarChart 水平返回 nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBarChart_SetData 测试设置柱状图数据
|
||||||
|
func TestBarChart_SetData(t *testing.T) {
|
||||||
|
chart := NewBarChart(60, 10, false)
|
||||||
|
|
||||||
|
// 测试设置数据
|
||||||
|
data := []BarDataItem{
|
||||||
|
{Label: "苹果", Value: 100},
|
||||||
|
{Label: "香蕉", Value: 80},
|
||||||
|
{Label: "橙子", Value: 120},
|
||||||
|
}
|
||||||
|
chart.SetData(data)
|
||||||
|
|
||||||
|
// 验证数据设置正确
|
||||||
|
if len(chart.Data) != len(data) {
|
||||||
|
t.Errorf("数据长度不匹配: got %d, want %d", len(chart.Data), len(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试空数据
|
||||||
|
chart.SetData(nil)
|
||||||
|
if chart.Data != nil {
|
||||||
|
t.Error("设置 nil 后数据不为 nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBarChart_Render 测试渲染柱状图
|
||||||
|
func TestBarChart_Render(t *testing.T) {
|
||||||
|
// 测试空数据渲染
|
||||||
|
chart := NewBarChart(60, 10, false)
|
||||||
|
result := chart.Render()
|
||||||
|
if result == "" {
|
||||||
|
t.Error("空数据渲染结果为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试有数据渲染
|
||||||
|
chart.SetData([]BarDataItem{
|
||||||
|
{Label: "苹果", Value: 100},
|
||||||
|
{Label: "香蕉", Value: 80},
|
||||||
|
})
|
||||||
|
result = chart.Render()
|
||||||
|
if result == "" {
|
||||||
|
t.Error("有数据渲染结果为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试有标题渲染
|
||||||
|
chart.SetTitle("水果销量")
|
||||||
|
result = chart.Render()
|
||||||
|
if result == "" {
|
||||||
|
t.Error("有标题渲染结果为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试水平柱状图
|
||||||
|
chartH := NewBarChart(60, 10, true)
|
||||||
|
chartH.SetData([]BarDataItem{
|
||||||
|
{Label: "A", Value: 50},
|
||||||
|
{Label: "B", Value: 30},
|
||||||
|
})
|
||||||
|
result = chartH.Render()
|
||||||
|
if result == "" {
|
||||||
|
t.Error("水平柱状图渲染结果为空")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScatterChart_NewScatterChart 测试创建散点图
|
||||||
|
func TestScatterChart_NewScatterChart(t *testing.T) {
|
||||||
|
chart := NewScatterChart(50, 20)
|
||||||
|
if chart == nil {
|
||||||
|
t.Error("NewScatterChart 返回 nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScatterChart_SetData 测试设置散点图数据
|
||||||
|
func TestScatterChart_SetData(t *testing.T) {
|
||||||
|
chart := NewScatterChart(50, 20)
|
||||||
|
|
||||||
|
// 测试设置数据
|
||||||
|
data := []ScatterDataItem{
|
||||||
|
{X: 1.0, Y: 5.0},
|
||||||
|
{X: 2.0, Y: 8.0},
|
||||||
|
{X: 3.0, Y: 3.0},
|
||||||
|
}
|
||||||
|
chart.SetData(data)
|
||||||
|
|
||||||
|
// 验证数据设置正确
|
||||||
|
if len(chart.Data) != len(data) {
|
||||||
|
t.Errorf("数据长度不匹配: got %d, want %d", len(chart.Data), len(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScatterChart_Render 测试渲染散点图
|
||||||
|
func TestScatterChart_Render(t *testing.T) {
|
||||||
|
// 测试空数据渲染
|
||||||
|
chart := NewScatterChart(50, 20)
|
||||||
|
result := chart.Render()
|
||||||
|
if result == "" {
|
||||||
|
t.Error("空数据渲染结果为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试有数据渲染
|
||||||
|
chart.SetData([]ScatterDataItem{
|
||||||
|
{X: 1.0, Y: 5.0},
|
||||||
|
{X: 2.0, Y: 8.0},
|
||||||
|
{X: 3.0, Y: 3.0},
|
||||||
|
})
|
||||||
|
result = chart.Render()
|
||||||
|
if result == "" {
|
||||||
|
t.Error("有数据渲染结果为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试有标题渲染
|
||||||
|
chart.SetTitle("成绩分布")
|
||||||
|
result = chart.Render()
|
||||||
|
if result == "" {
|
||||||
|
t.Error("有标题渲染结果为空")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScatterChart_findMinMax 测试散点图最小最大值
|
||||||
|
func TestScatterChart_findMinMax(t *testing.T) {
|
||||||
|
chart := NewScatterChart(50, 20)
|
||||||
|
|
||||||
|
// 测试空数据
|
||||||
|
minX, maxX := chart.findMinMaxX()
|
||||||
|
minY, maxY := chart.findMinMaxY()
|
||||||
|
if minX != 0 || maxX != 1 || minY != 0 || maxY != 1 {
|
||||||
|
t.Errorf("空数据范围错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试有数据
|
||||||
|
chart.SetData([]ScatterDataItem{
|
||||||
|
{X: 1.0, Y: 5.0},
|
||||||
|
{X: 2.0, Y: 8.0},
|
||||||
|
{X: 3.0, Y: 3.0},
|
||||||
|
})
|
||||||
|
minX, maxX = chart.findMinMaxX()
|
||||||
|
minY, maxY = chart.findMinMaxY()
|
||||||
|
|
||||||
|
if minX >= maxX || minY >= maxY {
|
||||||
|
t.Error("数<><E695B0><EFBFBD>范围错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
337
tools/provider.go
Normal file
337
tools/provider.go
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
// Package tools ttychart-mcp 工具实现包
|
||||||
|
//
|
||||||
|
// 该包提供所有 MCP 工具的具体实现,包括:
|
||||||
|
// - plot_line: 折线图
|
||||||
|
// - plot_bar: 柱状图
|
||||||
|
// - plot_scatter: 散点图
|
||||||
|
//
|
||||||
|
// 作者: titor
|
||||||
|
// 创建日期: 2026-04-15
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hub.gaomia.site/titor/ttychart-mcp/tools/charts"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
|
"github.com/mark3labs/mcp-go/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewServer 创建新的 MCP 服务器
|
||||||
|
func NewServer() *server.MCPServer {
|
||||||
|
srv := server.NewMCPServer(
|
||||||
|
"ttychart",
|
||||||
|
"0.1.0",
|
||||||
|
server.WithToolCapabilities(true),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 折线图工具
|
||||||
|
plotLineTool := mcp.NewTool(
|
||||||
|
"plot_line",
|
||||||
|
mcp.WithDescription("绘制折线图,用于展示数据随时间或类别的变化趋势"),
|
||||||
|
mcp.WithNumber("data",
|
||||||
|
mcp.Required(),
|
||||||
|
mcp.Description("Y轴数据点数组"),
|
||||||
|
),
|
||||||
|
mcp.WithString("title",
|
||||||
|
mcp.Description("图表标题"),
|
||||||
|
),
|
||||||
|
mcp.WithNumber("width",
|
||||||
|
mcp.Description("图表宽度(字符数),默认自动计算"),
|
||||||
|
),
|
||||||
|
mcp.WithNumber("height",
|
||||||
|
mcp.Description("图表高度(行数),默认自动计算"),
|
||||||
|
),
|
||||||
|
mcp.WithString("labels",
|
||||||
|
mcp.Description("X轴标签,多个标签用逗号分隔"),
|
||||||
|
),
|
||||||
|
mcp.WithString("color",
|
||||||
|
mcp.Description("线条颜色,可选值: red, green, blue, yellow, cyan, magenta"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 柱状图工具
|
||||||
|
plotBarTool := mcp.NewTool(
|
||||||
|
"plot_bar",
|
||||||
|
mcp.WithDescription("绘制柱状图,用于展示分类数据的比较"),
|
||||||
|
mcp.WithString("data",
|
||||||
|
mcp.Required(),
|
||||||
|
mcp.Description("数据,格式: 标签1:数值1,标签2:数值2"),
|
||||||
|
),
|
||||||
|
mcp.WithString("title",
|
||||||
|
mcp.Description("图表标题"),
|
||||||
|
),
|
||||||
|
mcp.WithNumber("width",
|
||||||
|
mcp.Description("图表宽度"),
|
||||||
|
),
|
||||||
|
mcp.WithNumber("height",
|
||||||
|
mcp.Description("图表高度"),
|
||||||
|
),
|
||||||
|
mcp.WithBoolean("horizontal",
|
||||||
|
mcp.Description("是否为水平柱状图,默认为垂直"),
|
||||||
|
),
|
||||||
|
mcp.WithString("color",
|
||||||
|
mcp.Description("柱体颜色"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 散点图工具
|
||||||
|
plotScatterTool := mcp.NewTool(
|
||||||
|
"plot_scatter",
|
||||||
|
mcp.WithDescription("绘制散点图,用于展示数据的相关性"),
|
||||||
|
mcp.WithString("data",
|
||||||
|
mcp.Required(),
|
||||||
|
mcp.Description("数据点,格式: x1,y1 x2,y2 ..."),
|
||||||
|
),
|
||||||
|
mcp.WithString("title",
|
||||||
|
mcp.Description("图表标题"),
|
||||||
|
),
|
||||||
|
mcp.WithNumber("width",
|
||||||
|
mcp.Description("图表宽度"),
|
||||||
|
),
|
||||||
|
mcp.WithNumber("height",
|
||||||
|
mcp.Description("图表高度"),
|
||||||
|
),
|
||||||
|
mcp.WithString("x_label",
|
||||||
|
mcp.Description("X轴标签"),
|
||||||
|
),
|
||||||
|
mcp.WithString("y_label",
|
||||||
|
mcp.Description("Y轴标签"),
|
||||||
|
),
|
||||||
|
mcp.WithString("color",
|
||||||
|
mcp.Description("点颜色"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
srv.AddTool(plotLineTool, handlePlotLineTool)
|
||||||
|
srv.AddTool(plotBarTool, handlePlotBarTool)
|
||||||
|
srv.AddTool(plotScatterTool, handlePlotScatterTool)
|
||||||
|
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePlotLineTool 处理折线图工具调用
|
||||||
|
func handlePlotLineTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
// 将请求参数转换为 JSON 再解析
|
||||||
|
jsonBytes, err := json.Marshal(request.Params)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultError("参数解析失败"), err
|
||||||
|
}
|
||||||
|
|
||||||
|
var args struct {
|
||||||
|
Data interface{} `json:"data"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
Labels string `json:"labels"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(jsonBytes, &args); err != nil {
|
||||||
|
return mcp.NewToolResultError("参数解析失败"), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理数据
|
||||||
|
var data []float64
|
||||||
|
switch v := args.Data.(type) {
|
||||||
|
case float64:
|
||||||
|
data = []float64{v}
|
||||||
|
case []interface{}:
|
||||||
|
data = make([]float64, 0, len(v))
|
||||||
|
for _, item := range v {
|
||||||
|
if f, ok := toFloat64(item); ok {
|
||||||
|
data = append(data, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) == 0 {
|
||||||
|
return mcp.NewToolResultError("错误: data 参数无效"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析标签
|
||||||
|
var labels []string
|
||||||
|
if args.Labels != "" {
|
||||||
|
labels = strings.Split(args.Labels, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取颜色
|
||||||
|
color := getColorStyle(args.Color)
|
||||||
|
|
||||||
|
// 生成图表
|
||||||
|
graph := charts.NewLineChart(args.Width, args.Height)
|
||||||
|
graph.SetData(data)
|
||||||
|
graph.SetTitle(args.Title)
|
||||||
|
graph.SetLabels(labels)
|
||||||
|
graph.SetColor(color)
|
||||||
|
|
||||||
|
return mcp.NewToolResultText(graph.Render()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePlotBarTool 处理柱状图工具调用
|
||||||
|
func handlePlotBarTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
jsonBytes, err := json.Marshal(request.Params)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultError("参数解析失败"), err
|
||||||
|
}
|
||||||
|
|
||||||
|
var args struct {
|
||||||
|
Data interface{} `json:"data"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
Horizontal bool `json:"horizontal"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(jsonBytes, &args); err != nil {
|
||||||
|
return mcp.NewToolResultError("参数解析失败"), err
|
||||||
|
}
|
||||||
|
|
||||||
|
dataStr, ok := args.Data.(string)
|
||||||
|
if !ok {
|
||||||
|
return mcp.NewToolResultError("错误: data 参数格式无效"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析数据: "标签1:数值1,标签2:数值2"
|
||||||
|
data := parseBarData(dataStr)
|
||||||
|
if len(data) == 0 {
|
||||||
|
return mcp.NewToolResultError("错误: data 参数格式无效"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
color := getColorStyle(args.Color)
|
||||||
|
|
||||||
|
graph := charts.NewBarChart(args.Width, args.Height, args.Horizontal)
|
||||||
|
graph.SetData(data)
|
||||||
|
graph.SetTitle(args.Title)
|
||||||
|
graph.SetColor(color)
|
||||||
|
|
||||||
|
return mcp.NewToolResultText(graph.Render()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePlotScatterTool 处理散点图工具调用
|
||||||
|
func handlePlotScatterTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
jsonBytes, err := json.Marshal(request.Params)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultError("参数解析失败"), err
|
||||||
|
}
|
||||||
|
|
||||||
|
var args struct {
|
||||||
|
Data interface{} `json:"data"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
XLabel string `json:"x_label"`
|
||||||
|
YLabel string `json:"y_label"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(jsonBytes, &args); err != nil {
|
||||||
|
return mcp.NewToolResultError("参数解析失败"), err
|
||||||
|
}
|
||||||
|
|
||||||
|
dataStr, ok := args.Data.(string)
|
||||||
|
if !ok {
|
||||||
|
return mcp.NewToolResultError("错误: data 参数格式无效"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析数据: "x1,y1 x2,y2 ..."
|
||||||
|
data := parseScatterData(dataStr)
|
||||||
|
if len(data) == 0 {
|
||||||
|
return mcp.NewToolResultError("错误: data 参数格式无效"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
color := getColorStyle(args.Color)
|
||||||
|
|
||||||
|
graph := charts.NewScatterChart(args.Width, args.Height)
|
||||||
|
graph.SetData(data)
|
||||||
|
graph.SetTitle(args.Title)
|
||||||
|
graph.SetXLabel(args.XLabel)
|
||||||
|
graph.SetYLabel(args.YLabel)
|
||||||
|
graph.SetColor(color)
|
||||||
|
|
||||||
|
return mcp.NewToolResultText(graph.Render()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBarData 解析柱状图数据
|
||||||
|
// 格式: "标签1:数值1,标签2:数值2"
|
||||||
|
func parseBarData(str string) []charts.BarDataItem {
|
||||||
|
var result []charts.BarDataItem
|
||||||
|
items := strings.Split(str, ",")
|
||||||
|
for _, item := range items {
|
||||||
|
parts := strings.SplitN(item, ":", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
if value, err := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64); err == nil {
|
||||||
|
result = append(result, charts.BarDataItem{
|
||||||
|
Label: strings.TrimSpace(parts[0]),
|
||||||
|
Value: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseScatterData 解析散点图数据
|
||||||
|
// 格式: "x1,y1 x2,y2 ..."
|
||||||
|
func parseScatterData(str string) []charts.ScatterDataItem {
|
||||||
|
var result []charts.ScatterDataItem
|
||||||
|
items := strings.Fields(str)
|
||||||
|
for _, item := range items {
|
||||||
|
parts := strings.SplitN(item, ",", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
x, err1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
|
||||||
|
y, err2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
||||||
|
if err1 == nil && err2 == nil {
|
||||||
|
result = append(result, charts.ScatterDataItem{
|
||||||
|
X: x,
|
||||||
|
Y: y,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// getColorStyle 获取颜色样式
|
||||||
|
func getColorStyle(colorName string) lipgloss.Style {
|
||||||
|
switch colorName {
|
||||||
|
case "red":
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
|
||||||
|
case "green":
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("10"))
|
||||||
|
case "blue":
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("12"))
|
||||||
|
case "yellow":
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("11"))
|
||||||
|
case "cyan":
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("14"))
|
||||||
|
case "magenta":
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("13"))
|
||||||
|
default:
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("15"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toFloat64 将 interface{} 转换为 float64
|
||||||
|
func toFloat64(v interface{}) (float64, bool) {
|
||||||
|
switch n := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return n, true
|
||||||
|
case int:
|
||||||
|
return float64(n), true
|
||||||
|
case int64:
|
||||||
|
return float64(n), true
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 这里添加 fmt 的使用以避免导入错误
|
||||||
|
var _ = fmt.Sprintf
|
||||||
171
tools/provider_test.go
Normal file
171
tools/provider_test.go
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
// Package tools MCP 工具集成测试
|
||||||
|
//
|
||||||
|
// 对所有 MCP 工具进行集成测试
|
||||||
|
//
|
||||||
|
// 作者: titor
|
||||||
|
// 创建日期: 2026-04-15
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestParseBarData 测试柱状图数据解析
|
||||||
|
func TestParseBarData(t *testing.T) {
|
||||||
|
// 测试正常解析
|
||||||
|
data := parseBarData("苹果:100,香蕉:80,橙子:120")
|
||||||
|
if len(data) != 3 {
|
||||||
|
t.Errorf("解析数据长度错误: got %d, want 3", len(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证解析结果
|
||||||
|
if len(data) > 0 {
|
||||||
|
if data[0].Label != "苹果" {
|
||||||
|
t.Errorf("标签解析错误: got %s, want 苹果", data[0].Label)
|
||||||
|
}
|
||||||
|
if data[0].Value != 100 {
|
||||||
|
t.Errorf("数值解析错误: got %f, want 100", data[0].Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试空字符串
|
||||||
|
data = parseBarData("")
|
||||||
|
if len(data) != 0 {
|
||||||
|
t.Error("空字符串应该返回空数据")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试错误格式
|
||||||
|
data = parseBarData("无效数据")
|
||||||
|
if len(data) != 0 {
|
||||||
|
t.Error("错误格式应该返回空数据")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试部分有效
|
||||||
|
data = parseBarData("有效:100,无效abc")
|
||||||
|
if len(data) != 1 {
|
||||||
|
t.Error("应该只解析有效部分")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseScatterData 测试散点图数据解析
|
||||||
|
func TestParseScatterData(t *testing.T) {
|
||||||
|
// 测试正常解析
|
||||||
|
data := parseScatterData("1,5 2,8 3,3")
|
||||||
|
if len(data) != 3 {
|
||||||
|
t.Errorf("解析数据长度错误: got %d, want 3", len(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证解析结果
|
||||||
|
if len(data) > 0 {
|
||||||
|
if data[0].X != 1 || data[0].Y != 5 {
|
||||||
|
t.Errorf("数据解析错误: got (%f,%f), want (1,5)", data[0].X, data[0].Y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试空字符串
|
||||||
|
data = parseScatterData("")
|
||||||
|
if len(data) != 0 {
|
||||||
|
t.Error("空字符串应该返回空数据")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试错误格式
|
||||||
|
data = parseScatterData("invalid")
|
||||||
|
if len(data) != 0 {
|
||||||
|
t.Error("错误格式应该返回空数据")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试部分有效
|
||||||
|
data = parseScatterData("1,5 abc,def")
|
||||||
|
if len(data) != 1 {
|
||||||
|
t.Error("应该只解析有效部分")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewServer 测试创建 MCP 服务器
|
||||||
|
func TestNewServer(t *testing.T) {
|
||||||
|
// 创建服务器
|
||||||
|
srv := NewServer()
|
||||||
|
if srv == nil {
|
||||||
|
t.Error("NewServer 返回 nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestColorStyle 测试颜色样式
|
||||||
|
func TestColorStyle(t *testing.T) {
|
||||||
|
// 测试各种颜色
|
||||||
|
colors := []string{"red", "green", "blue", "yellow", "cyan", "magenta", "unknown"}
|
||||||
|
|
||||||
|
for _, color := range colors {
|
||||||
|
style := getColorStyle(color)
|
||||||
|
if style.GetForeground() == nil {
|
||||||
|
t.Errorf("颜色 %s 返回无效样式", color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestToFloat64 测试类型转换
|
||||||
|
func TestToFloat64(t *testing.T) {
|
||||||
|
// 测试 float64
|
||||||
|
f, ok := toFloat64(float64(1.5))
|
||||||
|
if !ok || f != 1.5 {
|
||||||
|
t.Error("float64 转换失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 int
|
||||||
|
f, ok = toFloat64(int(10))
|
||||||
|
if !ok || f != 10 {
|
||||||
|
t.Error("int 转换失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 int64
|
||||||
|
f, ok = toFloat64(int64(20))
|
||||||
|
if !ok || f != 20 {
|
||||||
|
t.Error("int64 转换失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 string (应该失败)
|
||||||
|
_, ok = toFloat64("invalid")
|
||||||
|
if ok {
|
||||||
|
t.Error("string 转换应该失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试其他类型 (应该失败)
|
||||||
|
_, ok = toFloat64(nil)
|
||||||
|
if ok {
|
||||||
|
t.Error("nil 转换应该失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetOptionalInt 测试可选整数获取
|
||||||
|
func TestGetOptionalInt(t *testing.T) {
|
||||||
|
// 这里我们测试一个有默认值的场景
|
||||||
|
// 由于 mcp.CallToolParams 是内部结构,我们用模拟的方式测试
|
||||||
|
|
||||||
|
// 测试解析错误的数据格式
|
||||||
|
_ = lipgloss.NewStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseDataErrors 测试各种数据解析错误
|
||||||
|
func TestParseDataErrors(t *testing.T) {
|
||||||
|
// 测试各种边界情况
|
||||||
|
|
||||||
|
// 柱状图 - 正常格式
|
||||||
|
data := parseBarData("标签:100")
|
||||||
|
if len(data) != 1 {
|
||||||
|
t.Error("应该解析正常格式")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 散点图 - 负数
|
||||||
|
scatterData := parseScatterData("-1,-5 -2,-8")
|
||||||
|
if len(scatterData) != 2 {
|
||||||
|
t.Error("应该解析负数")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 散点图 - 小数
|
||||||
|
scatterData = parseScatterData("1.5,2.5 3.5,4.5")
|
||||||
|
if len(scatterData) != 2 {
|
||||||
|
t.Error("应该解析小数")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user