Files
tslink/gui/shot_test.go
2026-07-26 15:26:13 +00:00

313 lines
10 KiB
Go

//go:build shots
package gui
import (
"image"
"image/png"
"log/slog"
"net/netip"
"os"
"testing"
"time"
"gioui.org/font"
"gioui.org/gpu/headless"
"gioui.org/io/input"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"tslink/core"
"tslink/netdiag"
)
// Offscreen renders of the changed UI, for eyeballing what no assertion can
// capture — glyph coverage at bold weights, legend wrapping, how full the chart
// looks with only a few samples.
//
// go test ./gui/ -tags shots -run TestShots
//
// Build-tagged so the normal suite stays GPU-free and font-config independent.
const shotDir = "/tmp/tslink-shots"
func shoot(t *testing.T, th *Theme, name string, size image.Point, w func(gtx C) D) {
t.Helper()
win, err := headless.NewWindow(size.X, size.Y)
if err != nil {
t.Skipf("no GPU backend: %v", err)
}
defer win.Release()
var r input.Router
ops := new(op.Ops)
// Two frames: the second takes the paths that depend on widget state.
for i := 0; i < 2; i++ {
ops.Reset()
gtx := layout.Context{
Ops: ops,
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
Constraints: layout.Exact(size),
Now: time.Now(),
Source: r.Source(),
}
paint.Fill(gtx.Ops, th.P.Bg)
w(gtx)
if err := win.Frame(ops); err != nil {
t.Fatalf("frame: %v", err)
}
}
img := image.NewRGBA(image.Rectangle{Max: size})
if err := win.Screenshot(img); err != nil {
t.Fatalf("screenshot: %v", err)
}
f, err := os.Create(shotDir + "/" + name + ".png")
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := png.Encode(f, img); err != nil {
t.Fatal(err)
}
t.Logf("wrote %s/%s.png", shotDir, name)
}
// realTheme builds the theme the way the app does, including the host's CJK
// font. Unlike testTheme this deliberately depends on the local font config —
// that is the thing under inspection.
func realTheme(t *testing.T) *Theme {
t.Helper()
fonts := LoadFonts()
if !fonts.HasCJK {
t.Skip("no CJK font on this host")
}
faces, err := LoadCJKFaces(fonts.CJKPath, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("cjk: %v", err)
}
fonts.Collection = append(fonts.Collection, faces...)
th := NewTheme(fonts, true)
th.Shaper = text.NewShaper(text.WithCollection(fonts.Collection))
th.Lang = LangZH
return th
}
func shotApp(t *testing.T, th *Theme) *App {
a := testApp(t)
a.th = th
return a
}
func TestShots(t *testing.T) {
if err := os.MkdirAll(shotDir, 0o755); err != nil {
t.Fatal(err)
}
th := realTheme(t)
// --- 1. CJK at every weight the UI uses -------------------------------
// The bug was that only weight 400 had a CJK face, so everything below
// rendered as tofu boxes. All five lines must show Chinese glyphs.
t.Run("cjk-weights", func(t *testing.T) {
weights := []struct {
w font.Weight
name string
}{
{font.Normal, "Normal 正文:延迟图谱 已关联 本机服务"},
{font.Medium, "Medium 按钮:重试 导出日志 刷新"},
{font.SemiBold, "SemiBold 标题:网络诊断 节点延迟"},
{font.Bold, "Bold 强调:局域网 转发规则"},
}
shoot(t, th, "cjk-weights", image.Pt(560, 200), func(gtx C) D {
return layout.UniformInset(SpaceLG).Layout(gtx, func(gtx C) D {
children := make([]layout.FlexChild, 0, len(weights)*2)
for _, w := range weights {
children = append(children, layout.Rigid(func(gtx C) D {
l := th.Text(SizeSubtitle, th.P.TextPri, w.name)
l.Font.Weight = w.w
return l.Layout(gtx)
}), VGap(SpaceSM))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
})
})
})
// --- 2. Splash, normal and stuck --------------------------------------
for _, tc := range []struct {
name string
age time.Duration
}{
{"splash", 10 * time.Second},
{"splash-stuck", 45 * time.Second},
} {
t.Run(tc.name, func(t *testing.T) {
a := shotApp(t, th)
steps := splashTestSteps()
for i := range steps {
if steps[i].State == core.StepRunning {
steps[i].Started = time.Now().Add(-tc.age)
}
}
st := core.State{
Phase: core.PhaseStarting, Steps: steps,
StartedAt: time.Now().Add(-tc.age),
}
shoot(t, th, tc.name, image.Pt(460, 450), func(gtx C) D {
return a.splash.Layout(a, gtx, st)
})
})
}
// --- 3. Chart + legend with 8 series and only 30s of history ----------
// Previously this filled ~2.5% of the plot and clipped the legend.
t.Run("chart", func(t *testing.T) {
a := shotApp(t, th)
p := a.peers
series := p.buildSeries(th, shotPeers())
shoot(t, th, "chart", image.Pt(760, 400), func(gtx C) D {
return layout.UniformInset(SpaceLG).Layout(gtx, func(gtx C) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return p.chartCard(a, gtx, series)
})
})
})
// --- 3b. Stat tiles: equal height with and without a hint -------------
t.Run("stat-tiles", func(t *testing.T) {
a := shotApp(t, th)
st := readyState(t)
snap := core.PeerSnapshot{Peers: []core.PeerInfo{
{ID: "n1", DisplayName: "a", Online: true, Linked: true},
}}
servers := buildServices(st.Config, snap)
shoot(t, th, "stat-tiles", image.Pt(1000, 160), func(gtx C) D {
return layout.UniformInset(SpaceLG).Layout(gtx, func(gtx C) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return a.overview.statRow(a, gtx, st, snap, servers)
})
})
})
// --- 4. Services card grouped per server ------------------------------
t.Run("services", func(t *testing.T) {
a := shotApp(t, th)
cfg := &core.Config{Connect: map[string][]core.ConnectRule{
"sfcraft": {{Protocol: "minecraft", LocalPort: 25566, DstAddr: "sfcraft.mc.homelab.ice:25565", LanMotd: "SFCraft Vanilla | 原版生电 1.21.8"}},
"mayday": {{Protocol: "minecraft", LocalPort: 25571, DstAddr: "mayday.mc.homelab.ice:25565"}},
"voice": {{Protocol: "udp", LocalPort: 24454, DstAddr: "mc.lxns.net:24454"}},
"l4d2_tcp": {{Protocol: "tcp", LocalPort: 27015, DstAddr: "server.l4d2.homelab.ice:27015"}},
"l4d2_udp": {{Protocol: "udp", LocalPort: 27015, DstAddr: "server.l4d2.homelab.ice:27015"}},
}}
snap := core.PeerSnapshot{Peers: []core.PeerInfo{
// One subnet router fronts every *.homelab.ice host, so they collapse
// under a single tsdns-homelab header with the hosts nested beneath.
{ID: "n1", DisplayName: "tsdns-homelab", Online: true, Linked: true,
LinkTags: []string{"sfcraft", "mayday", "l4d2_tcp", "l4d2_udp"}},
}}
servers := buildServices(cfg, snap)
shoot(t, th, "services", image.Pt(760, 480), func(gtx C) D {
return layout.UniformInset(SpaceLG).Layout(gtx, func(gtx C) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return a.overview.servicesCard(a, gtx, servers)
})
})
})
}
// TestShotsDiag renders the UDP table, which must name servers rather than
// print bare resolved addresses.
func TestShotsDiag(t *testing.T) {
if err := os.MkdirAll(shotDir, 0o755); err != nil {
t.Fatal(err)
}
th := realTheme(t)
a := shotApp(t, th)
a.current = pageDiag
st := readyState(t)
a.diag.report = diagShotReport()
shoot(t, th, "diag-udp", image.Pt(1120, 900), func(gtx C) D {
return a.diag.Layout(a, gtx, st)
})
}
// diagShotReport is a healthy report whose only complaint is an HTTP-only
// egress split — the case that must read as a yellow "may affect", not a red
// "is affecting".
func diagShotReport() *netdiag.Report {
rep := &netdiag.Report{
StartedAt: time.Now().Add(-18 * time.Second),
Duration: 17 * time.Second,
UDP: netdiag.UDPReport{
Status: netdiag.StatusOK, Summary: "UDP 可用", V4OK: true,
CNReachable: 2, CNTotal: 2, IntlReachabl: 1, IntlTotal: 2,
Probes: []netdiag.UDPProbe{
{Host: "stun.miwifi.com:3478", Target: "111.206.174.2:3478", Name: "小米",
Region: netdiag.RegionCN, OK: true, RTT: 12 * time.Millisecond,
Mapped: netip.MustParseAddrPort("1.2.3.4:54321")},
{Host: "stun.miwifi.com:3478", Target: "[2408::1]:3478", Name: "小米",
Region: netdiag.RegionCN, OK: true, RTT: 15 * time.Millisecond,
Mapped: netip.MustParseAddrPort("[2001:db8::9]:54321")},
{Host: "stun.chat.bilibili.com:3478", Target: "203.107.1.33:3478", Name: "哔哩哔哩",
Region: netdiag.RegionCN, OK: true, RTT: 21 * time.Millisecond,
Mapped: netip.MustParseAddrPort("1.2.3.4:54322")},
{Host: "stun.l.google.com:19302", Target: "stun.l.google.com:19302", Name: "Google",
Region: netdiag.RegionIntl, Err: "i/o timeout"},
},
},
NAT: netdiag.NATReport{
Status: netdiag.StatusOK, Type: netdiag.NATFullCone,
Mapping: netdiag.BehaviorEndpointIndependent,
Filtering: netdiag.BehaviorUnknown,
},
Overseas: netdiag.OverseasReport{Status: netdiag.StatusOK, Summary: "境外可达"},
Egress: netdiag.EgressReport{
Observations: []netdiag.EgressObservation{
{Method: netdiag.MethodSTUN, Source: "stun.miwifi.com:3478", IP: netip.MustParseAddr("1.2.3.4")},
{Method: netdiag.MethodHTTPv4, Source: "https://example/ip", IP: netip.MustParseAddr("5.6.7.8")},
},
},
}
eg := &rep.Egress
eg.UniqueIPs = []netip.Addr{netip.MustParseAddr("1.2.3.4"), netip.MustParseAddr("5.6.7.8")}
eg.Divergent = true
eg.DivergentSTUN = false
eg.Status = netdiag.StatusWarn
eg.Summary = "出口 IP 不一致:IPv4 有 2 个(1.2.3.4、5.6.7.8),仅 HTTP 探测存在差异"
rep.Status = netdiag.StatusWarn
rep.Headline = "仅 HTTP 探测到多个出口 IP,代理或分流工具可能影响连接"
return rep
}
// shotPeers fabricates eight linked peers with ~30 seconds of history each —
// the short-uptime case the chart used to render almost entirely blank, and
// enough series to force the legend to wrap.
func shotPeers() []core.PeerInfo {
now := time.Now()
names := []string{
"sfcraft-homelab", "mayday", "l4d2-server", "voice-relay",
"mcp2-survival", "backup-node", "gateway-cn", "storage-nas",
}
peers := make([]core.PeerInfo, 0, len(names))
for i, n := range names {
var samples []core.PeerSample
for k := 0; k < 4; k++ {
samples = append(samples, core.PeerSample{
At: now.Add(time.Duration(-30+k*10) * time.Second),
Latency: time.Duration(18+i*9+k*4) * time.Millisecond,
OK: true,
})
}
peers = append(peers, core.PeerInfo{
ID: n, DisplayName: n, Online: true, Linked: true,
LinkTags: []string{n}, Route: core.RouteDirect,
LastLatency: samples[len(samples)-1].Latency, LatencyOK: true,
Samples: samples,
})
}
return peers
}