fix gui
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
//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{
|
||||
{ID: "n1", DisplayName: "homelab-mc", Online: true, Linked: true, LinkTags: []string{"sfcraft"}},
|
||||
{ID: "n2", DisplayName: "l4d2-box", Online: false, Linked: true, LinkTags: []string{"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
|
||||
}
|
||||
Reference in New Issue
Block a user