Files
ryan 6f5aaa9f85 fix(价格展示): 修复模板渲染时极小价格被折叠为 0.00 的问题
Motivation:
此前的修复仅覆盖直接拼接文案的场景,跟单等基于模板渲染的推送场景中,%.2f 格式化仍会把 PEPE 等 Meme 币的极小价格(如 0.00000059)折叠为 0.00,导致交易通知丢失真实价格、误导用户。

Changes:

* 抽取共享的价格显示逻辑到统一显示层,两个订阅者改为复用,移除重复实现
* 新增 Decimal 类型,渲染时以副本方式包装模板数据,使模板内 printf 风格格式化保留极小价格精度且不出现科学计数法,同时不修改调用方原始数据
* 渲染引擎空值判断改用反射实现,覆盖全部整型、无符号整型与浮点类型
* 补充转换到渲染的端到端回归测试,覆盖极小价格精度、科学计数法与数据不可变性
2026-08-23 00:12:41 +08:00

239 lines
8.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package engine
import (
"strings"
"testing"
)
func TestRenderer(t *testing.T) {
r := NewRenderer()
tmpl := "🚀 {{.symbol}} 开仓通知\n价格: {{.price}}"
data := map[string]interface{}{"symbol": "BTC", "price": 65000}
result, err := r.Render(tmpl, data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(result, "BTC") || !strings.Contains(result, "65000") {
t.Errorf("unexpected output: %s", result)
}
}
func TestRendererMissingKeyEmpty(t *testing.T) {
r := NewRenderer()
_, err := r.Render("x{{.nonexistent}}y", map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
}
func TestRendererLineOmitsEmpty(t *testing.T) {
r := NewRenderer()
tmpl := `{{line "币种" .symbol}}{{line "平均价" .totalAvgPx}}{{line "价格" .price}}{{line "止损价" .stopLossPrice}}`
out, err := r.Render(tmpl, map[string]interface{}{
"symbol": "ICP",
"price": 2.273,
"totalAvgPx": "",
"stopLossPrice": float64(0),
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "币种:ICP") || !strings.Contains(out, "价格:2.273") {
t.Fatalf("out=%q", out)
}
if strings.Contains(out, "平均价") || strings.Contains(out, "止损价") {
t.Fatalf("empty lines should be omitted, out=%q", out)
}
}
func TestRendererLineLabelAlreadyHasColon(t *testing.T) {
r := NewRenderer()
out, err := r.Render(`{{line "平均价:" .totalAvgPx}}`, map[string]interface{}{"totalAvgPx": 1029})
if err != nil {
t.Fatal(err)
}
if strings.Count(out, "") != 1 || !strings.Contains(out, "平均价:1029") {
t.Fatalf("out=%q", out)
}
}
func TestRendererLineMissingKey(t *testing.T) {
r := NewRenderer()
out, err := r.Render(`{{line "平均价" .totalAvgPx}}{{line "币种" .symbol}}`, map[string]interface{}{"symbol": "ICP"})
if err != nil {
t.Fatal(err)
}
if strings.Contains(out, "平均价") || !strings.Contains(out, "币种:ICP") {
t.Fatalf("out=%q", out)
}
}
func TestRendererCaseSwitch(t *testing.T) {
r := NewRenderer()
tmpl := `{{case .action "OPEN" "开仓" "CLOSE" "平仓" "GAIN" "止盈"}}`
out, err := r.Render(tmpl, map[string]interface{}{"action": "CLOSE"})
if err != nil {
t.Fatal(err)
}
if out != "平仓" {
t.Fatalf("action CLOSE: %q", out)
}
out, err = r.Render(tmpl, map[string]interface{}{"action": "open"})
if err != nil {
t.Fatal(err)
}
if out != "开仓" {
t.Fatalf("action open: %q", out)
}
}
func TestRendererCaseEventSuffix(t *testing.T) {
r := NewRenderer()
tmpl := `{{case .event ".open" "开仓" ".close" "平仓"}}`
out, err := r.Render(tmpl, map[string]interface{}{"event": "trade.close"})
if err != nil {
t.Fatal(err)
}
if out != "平仓" {
t.Fatalf("event trade.close: %q", out)
}
}
func TestRendererReplace(t *testing.T) {
r := NewRenderer()
out, err := r.Render(`{{replace .rawMessage "Time:" "推送时间:"}}`, map[string]interface{}{
"rawMessage": "激进版AI 1.0\n市价开空\nTime: 2026.08.17 14:46:04",
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "推送时间: 2026.08.17 14:46:04") || strings.Contains(out, "Time:") {
t.Fatalf("out=%q", out)
}
}
func TestRendererCaseDefault(t *testing.T) {
r := NewRenderer()
out, err := r.Render(`{{case .action "OPEN" "开仓" "未知"}}`, map[string]interface{}{"action": "HOLD"})
if err != nil {
t.Fatal(err)
}
if out != "未知" {
t.Fatalf("got %q", out)
}
}
// copyTradeTmpl is the production 跟单策略 template. It formats prices with
// printf "%.2f", which must not collapse meme-coin prices like PEPE to 0.00.
const copyTradeTmpl = "{{case .side \"LONG\" \"多单\" \"SHORT\" \"空单\"}}{{case .action \"OPEN\" \"开仓\" \"ADD\" \"加仓\" \"CLOSE\" \"平仓\" \"REDUCE\" \"减仓\"}}\n交易品种: {{case .symbol \"ETHUSDT\" \"ETH\" \"BTCUSDT\" \"BTC\" \"SOLUSDT\" \"SOL\" \"BNBUSDT\" \"BNB\" .symbol}}\n{{case .action \"OPEN\" \"开仓价格\" \"ADD\" \"加仓价格\" \"CLOSE\" \"平仓价格\" \"REDUCE\" \"减仓价格\"}}: {{printf \"%.2f\" .price}}\n{{case .action \"OPEN\" \"开仓数量\" \"ADD\" \"加仓数量\" \"CLOSE\" \"平仓数量\" \"REDUCE\" \"减仓数量\"}}: {{printf \"%.2f\" .quantity}}\n平均单价: {{printf \"%.2f\" .avgPrice}}\n{{if or (eq .action \"OPEN\") (eq .action \"ADD\")}}{{if .leverage}}杠杆: {{.leverage}}x\n{{end}}{{end}}策略: {{case .strategyCode \"BLONG\" \"B龙策略\" .strategyCode}}\n推送时间: {{.pushedAt}}"
func TestCopyTradeTemplate(t *testing.T) {
tmpl := copyTradeTmpl
r := NewRenderer()
cases := []struct {
name string
data map[string]interface{}
want []string
not []string
}{
{
name: "open",
data: map[string]interface{}{
"side": "SHORT", "action": "OPEN", "symbol": "ETHUSDT",
"price": 1898.76, "quantity": 2.0, "avgPrice": 1898.76,
"leverage": 100, "strategyCode": "BLONG", "pushedAt": "2026.08.13 13:15:42",
},
want: []string{"空单开仓", "交易品种: ETH", "开仓价格: 1898.76", "开仓数量: 2.00", "平均单价: 1898.76", "杠杆: 100x", "策略: B龙策略", "推送时间: 2026.08.13 13:15:42"},
},
{
name: "close",
data: map[string]interface{}{
"side": "SHORT", "action": "CLOSE", "symbol": "ETHUSDT",
"price": 1883.35, "quantity": 2.0, "avgPrice": 1898.76,
"leverage": 100, "strategyCode": "BLONG", "pushedAt": "2026.08.13 17:14:31",
},
want: []string{"空单平仓", "平仓价格: 1883.35", "平仓数量: 2.00", "策略: B龙策略", "推送时间: 2026.08.13 17:14:31"},
not: []string{"杠杆:"},
},
{
name: "reduce",
data: map[string]interface{}{
"side": "SHORT", "action": "REDUCE", "symbol": "ETHUSDT",
"price": 1889.43, "quantity": 3.1, "avgPrice": 1899.03,
"strategyCode": "BLONG", "pushedAt": "2026.08.12 22:05:02",
},
want: []string{"空单减仓", "减仓价格: 1889.43", "减仓数量: 3.10", "平均单价: 1899.03", "策略: B龙策略"},
not: []string{"杠杆:"},
},
{
name: "add",
data: map[string]interface{}{
"side": "SHORT", "action": "ADD", "symbol": "ETHUSDT",
"price": 1933.05, "quantity": 3.8, "avgPrice": 1933.05,
"leverage": 100, "strategyCode": "BLONG", "pushedAt": "2026.08.10 06:14:28",
},
want: []string{"空单加仓", "加仓价格: 1933.05", "加仓数量: 3.80", "杠杆: 100x", "策略: B龙策略", "推送时间: 2026.08.10 06:14:28"},
},
{
name: "pepe-open-keeps-tiny-price",
data: map[string]interface{}{
"side": "SHORT", "action": "OPEN", "symbol": "PEPEUSDT",
"price": 0.00000059, "quantity": 1000000000.0, "avgPrice": 0.00000059,
"leverage": 100, "strategyCode": "BLONG", "pushedAt": "2026.08.22 10:04:29",
},
want: []string{
"空单开仓", "交易品种: PEPEUSDT",
"开仓价格: 0.00000059", "开仓数量: 1000000000.00",
"平均单价: 0.00000059", "杠杆: 100x",
"策略: B龙策略", "推送时间: 2026.08.22 10:04:29",
},
not: []string{"开仓价格: 0.00\n", "平均单价: 0.00\n"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, err := r.Render(tmpl, tc.data)
if err != nil {
t.Fatal(err)
}
t.Logf("\n%s", out)
for _, w := range tc.want {
if !strings.Contains(out, w) {
t.Errorf("missing %q in\n%s", w, out)
}
}
for _, n := range tc.not {
if strings.Contains(out, n) {
t.Errorf("unexpected %q in\n%s", n, out)
}
}
})
}
}
func TestRendererTinyPriceNotScientificOrRounded(t *testing.T) {
r := NewRenderer()
data := map[string]interface{}{"price": 0.00000059, "stopLossPrice": 0.00000055}
out, err := r.Render(`价格: {{.price}}
{{line "止损价" .stopLossPrice}}`, data)
if err != nil {
t.Fatal(err)
}
if strings.Contains(out, "e-") || strings.Contains(out, "E-") {
t.Fatalf("tiny price lost precision:\n%s", out)
}
if !strings.Contains(out, "价格: 0.00000059") || !strings.Contains(out, "止损价:0.00000055") {
t.Fatalf("out=%q", out)
}
}
func TestRendererDoesNotMutateData(t *testing.T) {
r := NewRenderer()
data := map[string]interface{}{"price": 0.00000059}
if _, err := r.Render(`{{printf "%.2f" .price}}`, data); err != nil {
t.Fatal(err)
}
if _, ok := data["price"].(float64); !ok {
t.Fatalf("render mutated caller data: %T", data["price"])
}
}