// Copyright 2026 tamakyi. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. package report import ( "bytes" "image/png" "strings" "testing" "time" "github.com/stretchr/testify/require" "github.com/tamakyi/TamaBox/internal/db" "github.com/tamakyi/TamaBox/internal/security/geoip" ) // sampleSummary 构造一份含两国三城的汇总,供渲染测试复用。 func sampleSummary() *Summary { loc := time.Local start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc) period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)} return Build(Input{ Period: period, Sources: []*db.HoneypotSourceAgg{ {IP: "203.0.113.1", Hits: 60, Banned: 60, Creds: 5, Provider: "微软 Azure(云厂商)"}, {IP: "203.0.113.2", Hits: 30, Banned: 30, Creds: 2, Provider: "未识别(普通网络)"}, {IP: "198.51.100.9", Hits: 10, Banned: 10}, }, Paths: []*db.HoneypotPathStat{{Path: "/wp-login.php", Count: 70}}, Geo: map[string]geoip.Geo{ "203.0.113.1": {Country: "美国", CountryCode: "US", Region: "Virginia", City: "Ashburn", Lat: 39.04, Lon: -77.49, HasCoords: true, ISP: "Amazon", ASN: "AS14618"}, "203.0.113.2": {Country: "中国", CountryCode: "CN", Region: "北京市", City: "北京", Lat: 39.9, Lon: 116.4, HasCoords: true, ISP: "联通"}, "198.51.100.9": {Country: "德国", CountryCode: "DE", Region: "Berlin", City: "Berlin", Lat: 52.5, Lon: 13.4, HasCoords: true}, }, Now: start, }) } // TestRenderPNG 覆盖位图地图:必须是合法 PNG、尺寸固定、非空白。 // // 空白图是这里最容易出的问题:坐标算错时所有气泡会画到画布外, // 得到的仍是一张「合法但全白」的 PNG,肉眼要打开附件才发现。 func TestRenderPNG(t *testing.T) { sum := sampleSummary() data, err := RenderPNG(sum) require.NoError(t, err) require.True(t, len(data) > 1024, "PNG 太小,怀疑是空白图") img, err := png.Decode(bytes.NewReader(data)) require.NoError(t, err) require.Equal(t, mapWidth, img.Bounds().Dx()) require.Equal(t, mapHeight, img.Bounds().Dy()) // 统计与背景色不同的像素:气泡、网格、文字都会贡献非背景像素。 bg := img.At(1, 1) diff := 0 for y := 0; y < mapHeight; y += 4 { for x := 0; x < mapWidth; x += 4 { r1, g1, b1, _ := img.At(x, y).RGBA() r2, g2, b2, _ := bg.RGBA() if r1 != r2 || g1 != g2 || b1 != b2 { diff++ } } } require.Greater(t, diff, 500, "画面上几乎没有非背景像素,地图可能画空了") } // TestRenderPNGDropsBadCoords 覆盖越界坐标:不能画到 0,0 去。 func TestRenderPNGDropsBadCoords(t *testing.T) { loc := time.Local start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc) period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)} sum := Build(Input{ Period: period, Sources: []*db.HoneypotSourceAgg{{IP: "203.0.113.1", Hits: 5}}, Geo: map[string]geoip.Geo{ // HasCoords=true 但坐标是 (0,0):IP 库对「只知道国家」的常见输出。 "203.0.113.1": {Country: "未知海域", CountryCode: "XX", Lat: 0, Lon: 0, HasCoords: true}, }, Now: start, }) require.Empty(t, bubbles(sum), "(0,0) 必须被丢弃,否则会画到几内亚湾") } // TestRenderSVG 覆盖 SVG:每个有坐标的城市都应该有一个 circle。 func TestRenderSVG(t *testing.T) { sum := sampleSummary() svg := RenderSVG(sum) require.Contains(t, svg, "", "空报告不该渲染数据表") } // TestBubbleRadiusMonotonic 覆盖气泡缩放:命中多的必须更大,且线性增长不会 // 把长尾压得看不见(sqrt 缩放)。 func TestBubbleRadiusMonotonic(t *testing.T) { small := bubbleRadius(1, 1000) mid := bubbleRadius(100, 1000) big := bubbleRadius(1000, 1000) require.Less(t, small, mid) require.Less(t, mid, big) // 1/1000 的命中也要有可辨识的尺寸 require.Greater(t, small, 3.0) require.Less(t, big, 21.0) } // TestLabelPackerNoOverlap 是「AU 305」那个 bug 的回归测试。 // // 同一个都市圈的不同城市坐标只差 0.01°,两行标签会精确重叠,叠出一个 // 看起来像真实数字的假字符串(实测把 "AU 55" 与 "AU 30" 叠成了 "AU 305")。 // 读图的人不会怀疑「305 是从哪来的」,只会把它当结论 —— 所以宁可少标。 func TestLabelPackerNoOverlap(t *testing.T) { p := newLabelPacker(mapWidth, mapHeight) // 三个几乎重合的气泡:第一个标在右侧、第二个自动换到左侧(都清晰可读), // 第三个两侧都被占就只能放弃 —— 关键是它们不能叠在一起。 for i := 0; i < 3; i++ { x, y := project(-27.48+float64(i)*0.01, 153.02+float64(i)*0.01) p.place(x, y, 10, basicfontWidth("AU 55"), 13) } require.Len(t, p.placed, 2, "三个重合的气泡最多只能放下两个不重叠的标签") requireNoOverlap(t, p.placed) // 远离的气泡必须都能标上 p2 := newLabelPacker(mapWidth, mapHeight) require.True(t, mustPlace(t, p2, 100, 100)) require.True(t, mustPlace(t, p2, 400, 300)) require.Len(t, p2.placed, 2) requireNoOverlap(t, p2.placed) } // requireNoOverlap 断言任意两个标签框都不相交 —— 这是标签层的唯一不变量。 func requireNoOverlap(t *testing.T, boxes []labelBox) { t.Helper() for i := 0; i < len(boxes); i++ { for j := i + 1; j < len(boxes); j++ { a, b := boxes[i], boxes[j] overlap := a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1 require.False(t, overlap, "标签 %d 与 %d 重叠:%v / %v", i, j, a, b) } } } // TestLabelPackerClampsToCanvas 靠右边缘的气泡要把标签放到左边,而不是画出画布。 func TestLabelPackerClampsToCanvas(t *testing.T) { p := newLabelPacker(mapWidth, mapHeight) w := basicfontWidth("AU 55") // 气泡贴着右边缘:右侧放不下,应自动改到左侧 x, _, ok := p.place(mapWidth-12, 240, 10, w, 13) require.True(t, ok) require.LessOrEqual(t, x+w, float64(mapWidth-2)) // 两条边都放不下(画布外)应放弃 p2 := newLabelPacker(20, 20) _, _, ok = p2.place(10, 10, 40, w, 13) require.False(t, ok) } func mustPlace(t *testing.T, p *labelPacker, bx, by float64) bool { t.Helper() _, _, ok := p.place(bx, by, 10, basicfontWidth("US 90"), 13) return ok }