package question import ( "image" "image/color" "image/draw" "image/png" "os" "path/filepath" "testing" "github.com/golang/freetype" "github.com/golang/freetype/truetype" qrcode "github.com/skip2/go-qrcode" "github.com/tamakyi/TamaBox/internal/conf" "github.com/tamakyi/TamaBox/internal/db" ) // 分享卡测试需要一份真实字体;中文站点部署时 conf.App.FontPath 必然存在, // 本机开发/CI 缺失时跳过,避免把「没有字体」当成排版错误。 func TestMain(m *testing.M) { if _, err := os.Stat(conf.App.FontPath); err != nil { for _, candidate := range []string{ "C:/Windows/Fonts/NotoSansSC-VF.ttf", "C:/Windows/Fonts/msyh.ttc", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", } { if _, err := os.Stat(candidate); err == nil { conf.App.FontPath = candidate break } } } os.Exit(m.Run()) } func requireFont(t *testing.T) *truetype.Font { t.Helper() ft, err := getFont() if err != nil { t.Skipf("no usable font (%v), skip share card layout tests", err) } return ft } // measureTextWidth 必须与 freetype 实际笔位推进一致,否则居中会重新出现偏移。 func TestMeasureTextWidthMatchesAdvance(t *testing.T) { requireFont(t) cases := []struct { text string size float64 }{ {"https://box.shiroko.one/_/tamakyi", FontSizeLink}, {"box.shiroko.one/_/tamakyi", 13}, {"ask.some-quite-long-domain-name.example.com", LinkMinSize}, {"Powered by 狼的提问箱", FontSizeFooter}, {"扫码向我匿名提问", FontSizeQRHint}, } for _, c := range cases { dst := image.NewRGBA(image.Rect(0, 0, 1400, 80)) fctx := freetype.NewContext() fctx.SetFont(requireFont(t)) fctx.SetDPI(72) fctx.SetFontSize(c.size) fctx.SetSrc(image.NewUniform(color.Black)) fctx.SetDst(dst) pen, err := fctx.DrawString(c.text, freetype.Pt(0, 60)) if err != nil { t.Fatalf("draw %q: %v", c.text, err) } want := float64(pen.X) / 64.0 if got := measureTextWidth(c.text, c.size); got != want { t.Errorf("%q @%.1f: measure=%.2f, advance=%.2f", c.text, c.size, got, want) } } } // 任意长度的链接都必须收进卡片,且不得缩到不可读。 func TestLayoutLinkFitsCard(t *testing.T) { requireFont(t) urls := []string{ "box.shiroko.one/_/tamakyi", "box-dev-api.337845818.best/_/tamakyi", "ask.some-quite-long-domain-name.example.com/_/tamakyi", "questions.tamabox-community.example.org/anon/box/_/a-rather-long-handle-name", "averyveryveryverylongdomainnamethatkeepsgoingandonandonforever.example/_/x", } for _, u := range urls { lines, size := layoutLink(u) if size < LinkMinSize { t.Errorf("%q: font size %.2f below floor %.2f", u, size, LinkMinSize) } if len(lines) > 2 { t.Errorf("%q: got %d lines, want <=2", u, len(lines)) } for _, line := range lines { if w := measureTextWidth(line, size); w > float64(LinkMaxWidth) { t.Errorf("%q: line %q is %.1fpx, card allows %dpx", u, line, w, LinkMaxWidth) } } } } // 居中必须落在二维码卡片中线上(按真实着墨边界判断,允许左右侧边距带来的 3px 内偏差)。 func TestDrawTextCenteredIsCentered(t *testing.T) { requireFont(t) cx := QRCardX + QRCardW/2 for _, text := range []string{ "box.shiroko.one/_/tamakyi", "box-dev-api.337845818.best/_/tamakyi", "Powered by 狼的提问箱", } { canvas := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight)) draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ColorWhite}, image.Point{}, draw.Src) fctx := freetype.NewContext() fctx.SetFont(requireFont(t)) fctx.SetDPI(72) drawTextCentered(canvas, text, cx, 300, FontSizeLink, ColorDarkText, fctx) minX, maxX := -1, -1 for x := 0; x < CardWidth; x++ { for y := 250; y < 320; y++ { if r, _, _, a := canvas.At(x, y).RGBA(); a > 40 && r < 0x8000 { if minX < 0 { minX = x } maxX = x break } } } if minX < 0 { t.Fatalf("%q: nothing drawn", text) } if center := (minX + maxX) / 2; abs(center-cx) > 3 { t.Errorf("%q: ink center %d, want %d (±3)", text, center, cx) } if maxX > QRCardX+QRCardW || minX < QRCardX { t.Errorf("%q: ink [%d,%d] escapes card [%d,%d]", text, minX, maxX, QRCardX, QRCardX+QRCardW) } } } // 内嵌头像必须画在中心,且符号本身不被裁切(四角是定位图案,应当是黑的)。 func TestGenerateQRWithLogo(t *testing.T) { logo := image.NewRGBA(image.Rect(0, 0, 64, 40)) draw.Draw(logo, logo.Bounds(), &image.Uniform{color.RGBA{200, 40, 40, 255}}, image.Point{}, draw.Src) img, err := generateQR("https://box.shiroko.one/_/tamakyi", logo, QRCodeSize) if err != nil { t.Fatal(err) } cx, cy := img.Bounds().Dx()/2, img.Bounds().Dy()/2 r, _, _, _ := img.At(cx, cy).RGBA() if r < 0x8000 || r > 0xf000 { t.Errorf("center pixel should be the logo color, got %#x", r) } for _, p := range []image.Point{{0, 0}, {img.Bounds().Dx() - 1, 0}, {0, img.Bounds().Dy() - 1}} { r, g, b, _ := img.At(p.X, p.Y).RGBA() if r == 0xffff && g == 0xffff && b == 0xffff { t.Errorf("corner %v is white: finder pattern missing, symbol likely cropped", p) } } } func abs(v int) int { if v < 0 { return -v } return v } // 二维码必须按模块整数倍绘制:任何宽度落差的游程都意味着模块被拉伸, // 小尺寸(微信/QQ 二次压缩后)最容易出现「扫不动」。同时必须把目标尺寸用满, // 否则模块像素偏小,实际可扫距离大幅缩水。 func TestGenerateQRIsModuleAligned(t *testing.T) { const url = "https://box.shiroko.one/_/tamakyi" for _, size := range []int{QRCodeSize, QQRSize} { img, err := generateQR(url, nil, size) if err != nil { t.Fatal(err) } probe, err := qrcode.New(url, qrcode.High) if err != nil { t.Fatal(err) } probe.DisableBorder = true modules := len(probe.Bitmap()) perModule := size / modules want := modules * perModule if got := img.Bounds().Dx(); got != want { t.Fatalf("size %d: got width %d, want %d (%d modules x %dpx)", size, got, want, modules, perModule) } if got := img.Bounds().Dy(); got != want { t.Fatalf("size %d: got height %d, want %d", size, got, want) } // 至少用掉目标尺寸的 80%,防止静默区/留白再次吃掉模块像素 if float64(want) < 0.8*float64(size) { t.Fatalf("size %d: symbol only %dpx, wastes %.0f%% of the target", size, want, 100*(1-float64(want)/float64(size))) } // 逐行扫描:所有黑白游程都必须是模块宽度的整数倍 for _, y := range []int{want / 2, want / 3} { runLen := 0 for x := 0; x < want; x++ { r, _, _, _ := img.At(x, y).RGBA() black := r < 0x8000 if x == 0 { runLen = 1 continue } prevR, _, _, _ := img.At(x-1, y).RGBA() if (prevR < 0x8000) != black { if runLen%perModule != 0 { t.Fatalf("size %d: run of %dpx at x=%d is not a multiple of module %d", size, runLen, x-runLen, perModule) } runLen = 1 continue } runLen++ } if runLen%perModule != 0 { t.Fatalf("size %d: trailing run %dpx not a multiple of %d", size, runLen, perModule) } } } } // 模块像素是扫码能力的唯一硬指标:实测 5px/模块的卡缩到 320 宽仍可解码, // 4px 时缩到 480 就解不出。这里锁住常见链接长度下的模块尺寸。 func TestGenerateQRModuleSizeAtLeast5(t *testing.T) { for _, url := range []string{ "https://box.shiroko.one/_/tamakyi", "https://box-dev-api.337845818.best/_/tbunread1022181904", "https://box.shiroko.one/_/a-rather-long-handle-name", } { img, err := generateQR(url, nil, QRCodeSize) if err != nil { t.Fatal(err) } probe, err := qrcode.New(url, qrcode.High) if err != nil { t.Fatal(err) } probe.DisableBorder = true mods := len(probe.Bitmap()) if per := img.Bounds().Dx() / mods; per < 5 { t.Errorf("%s: %d modules in %dpx -> %dpx/module, want >=5", url, mods, img.Bounds().Dx(), per) } } } // 单题卡的二维码卡片:模块必须够大、整块落在卡内、提示语不越界。 func TestQuestionQRCardLayout(t *testing.T) { requireFont(t) saved := conf.App.ExternalURL conf.App.ExternalURL = "https://box-dev-api.337845818.best" t.Cleanup(func() { conf.App.ExternalURL = saved }) user := &db.User{Domain: "tbunread1022181904"} question := &db.Question{ID: 113} question.UserID = user.ID canvas := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight)) draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ColorWhite}, image.Point{}, draw.Src) fctx := freetype.NewContext() fctx.SetFont(requireFont(t)) fctx.SetDPI(72) if err := drawQuestionQRCard(canvas, user, question, nil, fctx); err != nil { t.Fatal(err) } // 二维码:着墨边界必须落在卡片内且四周留白 ≥20px,模块 ≥5px card := image.Rect(QQRCardX, QQRCardY, QQRCardX+QQRCardW, QQRCardY+QQRCardH) minX, minY, maxX, maxY := CardWidth, CardHeight, -1, -1 for y := card.Min.Y; y < card.Min.Y+220; y++ { for x := card.Min.X; x < card.Max.X; x++ { if r, _, _, _ := canvas.At(x, y).RGBA(); r < 0x4000 { if x < minX { minX = x } if y < minY { minY = y } if x > maxX { maxX = x } if y > maxY { maxY = y } } } } if maxX < 0 { t.Fatal("nothing drawn in the QR card") } size := maxX - minX + 1 if size != maxY-minY+1 { t.Fatalf("QR is not square: %dx%d", size, maxY-minY+1) } if per := size / qrModules(t, conf.App.ExternalURL+"/_/"+user.Domain+"/113"); per < 5 { t.Errorf("QR module is %dpx, want >=5", per) } for _, gap := range []struct { name string got, limit int }{ {"left", minX - card.Min.X, 20}, {"right", card.Max.X - maxX - 1, 20}, {"top", minY - card.Min.Y, 20}, {"bottom", card.Max.Y - maxY - 1, 20}, } { if gap.got < gap.limit { t.Errorf("%s margin %dpx < %dpx", gap.name, gap.got, gap.limit) } } if os.Getenv("TEST_PREVIEW") != "" { outDir := os.Getenv("TEST_PREVIEW_DIR") if outDir == "" { outDir = filepath.Join("..", "..", "outputs", "sharecard-preview") } if err := os.MkdirAll(outDir, 0o755); err != nil { t.Fatal(err) } f, err := os.Create(filepath.Join(outDir, "question-card.png")) if err != nil { t.Fatal(err) } if err := png.Encode(f, canvas); err != nil { t.Fatal(err) } f.Close() } } func qrModules(t *testing.T, content string) int { t.Helper() q, err := qrcode.New(content, qrcode.High) if err != nil { t.Fatal(err) } q.DisableBorder = true return len(q.Bitmap()) } // 视觉自检:TEST_PREVIEW=1 时把链接区渲成 PNG,人工确认断行与对齐。 func TestShareCardLinkPreview(t *testing.T) { if os.Getenv("TEST_PREVIEW") == "" { t.Skip("set TEST_PREVIEW=1 to write preview images") } requireFont(t) outDir := os.Getenv("TEST_PREVIEW_DIR") if outDir == "" { outDir = filepath.Join("..", "..", "outputs", "sharecard-preview") } if err := os.MkdirAll(outDir, 0o755); err != nil { t.Fatal(err) } cases := map[string]string{ "short": "box.shiroko.one/_/tamakyi", "dev": "box-dev-api.337845818.best/_/tamakyi", "long": "ask.some-quite-long-domain-name.example.com/_/tamakyi", "verylong": "questions.tamabox-community.example.org/anon/box/_/a-rather-long-handle-name", } for name, display := range cases { canvas := image.NewRGBA(image.Rect(0, 0, QRCardW, QRCardH)) draw.Draw(canvas, canvas.Bounds(), &image.Uniform{color.NRGBA{246, 246, 246, 255}}, image.Point{}, draw.Src) drawRoundedRect(canvas, canvas.Bounds(), ColorWhite, 18) fctx := freetype.NewContext() fctx.SetFont(requireFont(t)) fctx.SetDPI(72) lines, size := layoutLink(display) const qrToHintGap, hintLineHeight, hintToSepGap, sepHeight, sepToLinkGap, linkLineHeight = 24, 17, 20, 1, 20, 16 qr, err := generateQR("https://"+display, nil, QRCodeSize) if err != nil { t.Fatal(err) } qrSize := qr.Bounds().Dx() total := qrSize + qrToHintGap + hintLineHeight + hintToSepGap + sepHeight + sepToLinkGap + linkLineHeight*len(lines) startY := (QRCardH - total) / 2 draw.Draw(canvas, image.Rect((QRCardW-qrSize)/2, startY, (QRCardW+qrSize)/2, startY+qrSize), qr, image.Point{}, draw.Over) hintY := startY + qrSize + qrToHintGap + hintLineHeight drawTextCentered(canvas, "扫码向我匿名提问", QRCardW/2, hintY, FontSizeQRHint, ColorHintGray, fctx) sepY := hintY + hintToSepGap drawLine(canvas, QRCardW/2-90, sepY, QRCardW/2+90, sepY, color.NRGBA{200, 200, 200, 180}) linkY := sepY + sepToLinkGap + linkLineHeight for i, line := range lines { drawTextCentered(canvas, line, QRCardW/2, linkY+i*linkLineHeight, size, ColorDarkText, fctx) } f, err := os.Create(filepath.Join(outDir, name+".png")) if err != nil { t.Fatal(err) } if err := png.Encode(f, canvas); err != nil { t.Fatal(err) } f.Close() } }