Files
tslink/gui/render_test.go
T
2026-07-26 20:44:49 +08:00

375 lines
13 KiB
Go

package gui
import (
"image"
"log/slog"
"net/netip"
"testing"
"time"
"gioui.org/app"
"gioui.org/io/input"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/text"
"gioui.org/unit"
"tslink/core"
"tslink/netdiag"
)
// These tests lay out every page without a GPU or a window.
//
// Layout is where a Gio UI actually breaks: a negative constraint, an
// unbounded flex child or a nil dereference in a rarely-taken branch panics at
// draw time, and there is no compiler check for any of it. Measurement runs
// the full flex/stack/text-shaping path, so exercising it catches those
// without needing a display — which also means it runs in CI.
func testTheme(t *testing.T) *Theme {
t.Helper()
// Skip system font discovery: CI images have no CJK font and the walk
// would make the test depend on the host's font configuration.
fonts := &FontSet{Collection: goCollection(), UI: "Go", Mono: "Go Mono"}
th := NewTheme(fonts, true)
th.Shaper = text.NewShaper(text.NoSystemFonts(), text.WithCollection(fonts.Collection))
return th
}
// newTestContext builds a layout context backed by a real input router, so
// widgets that register event handlers behave as they do on screen.
func newTestContext(size image.Point) (layout.Context, *input.Router) {
var r input.Router
gtx := layout.Context{
Ops: new(op.Ops),
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
Constraints: layout.Exact(size),
Now: time.Now(),
Source: r.Source(),
}
return gtx, &r
}
// testApp builds an App with no supervisor, which is the state the GUI is in
// before the service comes up.
func testApp(t *testing.T) *App {
t.Helper()
logs := core.NewLogBuffer(256)
logger := slog.New(logs.Handler(nil))
for i := 0; i < 40; i++ {
logger.Info("synthetic log line", "i", i, "from", "test")
}
logger.Error("synthetic failure", "err", "boom", "from", "test")
a := New(Options{
Version: "test",
ConfigPath: "config.toml",
Logs: logs,
Logger: logger,
StartDark: true,
})
a.th = testTheme(t)
return a
}
// readyState fabricates a running service with a peer, a LAN server and a
// diagnostic report, so the populated branches of every page get exercised
// rather than just the empty states.
func readyState(t *testing.T) core.State {
t.Helper()
logger := slog.New(slog.DiscardHandler)
cfg := &core.Config{
Core: core.Core{Hostname: "test"},
Connect: map[string][]core.ConnectRule{
"survival": {{Protocol: "minecraft", LocalPort: 25565, DstAddr: "peer:25565"}},
},
Forward: map[string][]core.ForwardRule{
"web": {{Protocol: "tcp", TailscalePort: 80, LocalAddr: "127.0.0.1:8080"}},
},
}
return core.State{
Phase: core.PhaseReady,
Steps: nil,
StartedAt: time.Now().Add(-time.Hour),
ReadyAt: time.Now().Add(-time.Hour),
Config: cfg,
Peers: core.NewPeerMonitor(nil, cfg.Connect, logger, core.PeerMonitorOptions{}),
}
}
func TestPagesLayout(t *testing.T) {
sizes := []image.Point{
{X: 1200, Y: 800}, // roomy
{X: 880, Y: 560}, // the declared minimum window
{X: 640, Y: 400}, // below minimum: compact rail, everything must still fit
}
pages := []pageID{pageOverview, pagePeers, pageDiag, pageLogs, pageSettings}
for _, size := range sizes {
for _, page := range pages {
a := testApp(t)
a.current = page
st := readyState(t)
gtx, _ := newTestContext(size)
// Two frames: the first registers widget state, the second takes
// the paths that depend on it (hover, list position, caches).
for i := 0; i < 2; i++ {
a.shell(gtx, st)
}
}
}
}
func TestSplashLayout(t *testing.T) {
phases := []core.Phase{
core.PhaseIdle, core.PhaseStarting, core.PhaseRetrying,
core.PhaseError, core.PhaseStopped,
}
for _, phase := range phases {
for _, size := range []image.Point{{X: 1200, Y: 800}, {X: 880, Y: 560}, {X: 700, Y: 380}} {
a := testApp(t)
st := core.State{
Phase: phase,
Steps: splashTestSteps(),
StartedAt: time.Now().Add(-10 * time.Second),
Err: "core.auth_key is required",
}
gtx, _ := newTestContext(size)
a.splash.Layout(a, gtx, st)
}
}
}
// TestSplashStuck covers the >20s branch, which swaps the footer hint and
// promotes the export button.
func TestSplashStuck(t *testing.T) {
a := testApp(t)
steps := splashTestSteps()
for i := range steps {
if steps[i].State == core.StepRunning {
steps[i].Started = time.Now().Add(-45 * time.Second)
}
}
st := core.State{
Phase: core.PhaseStarting,
Steps: steps,
StartedAt: time.Now().Add(-45 * time.Second),
}
if !stalled(st) {
t.Fatal("stalled() should report a step running past stuckAfter")
}
gtx, _ := newTestContext(image.Pt(460, 450))
a.splash.Layout(a, gtx, st)
}
func splashTestSteps() []core.BootStep {
now := time.Now()
return []core.BootStep{
{Key: core.StepKeyConfig, State: core.StepDone, Started: now.Add(-3 * time.Second), Finished: now.Add(-2 * time.Second)},
{Key: core.StepKeyTsnet, State: core.StepRunning, Started: now.Add(-2 * time.Second)},
{Key: core.StepKeyRules, State: core.StepPending},
{Key: core.StepKeyServices, State: core.StepFailed, Err: "listen: address already in use"},
{Key: core.StepKeyMonitors, State: core.StepSkipped},
{Key: core.StepKeyReady, State: core.StepPending},
}
}
// TestDiagPageWithReport renders every diagnostic section with a populated
// report, including the awkward cases: tri-state unknowns, divergent egress,
// and a failed probe row.
func TestDiagPageWithReport(t *testing.T) {
a := testApp(t)
a.current = pageDiag
yes := true
rep := &netdiag.Report{
StartedAt: time.Now().Add(-20 * time.Second),
Duration: 18 * time.Second,
Status: netdiag.StatusWarn,
Headline: "对称型 NAT:与同样受限的对端难以打洞",
Interfaces: netdiag.InterfaceReport{
Status: netdiag.StatusOK,
Summary: "2 个接口 / 3 个地址",
DefaultV4Src: netip.MustParseAddr("192.168.1.23"),
Addrs: []netdiag.LocalAddr{
{Iface: "eth0", Addr: netip.MustParseAddr("192.168.1.23"), Kind: netdiag.AddrPrivateV4, Up: true, MTU: 1500, IsDefaultSrc: true},
{Iface: "tailscale0", Addr: netip.MustParseAddr("100.101.102.103"), Kind: netdiag.AddrTailscale, Up: true, MTU: 1280},
},
},
UDP: netdiag.UDPReport{
Status: netdiag.StatusWarn, Summary: "UDP 可用", V4OK: true,
CNReachable: 3, CNTotal: 5, IntlReachabl: 1, IntlTotal: 7,
BlockedPorts: []int{19302},
Probes: []netdiag.UDPProbe{
// A resolved probe: Host names the server, Target is the
// address actually hit, and the label must show the former.
{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")},
// DNS failed, so Target still holds the hostname.
{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.StatusFail, Type: netdiag.NATSymmetric,
Mapping: netdiag.BehaviorAddressAndPortDependent, Filtering: netdiag.BehaviorUnknown,
Hairpin: nil, PortPreserving: &yes,
MappedAddrs: []netip.AddrPort{netip.MustParseAddrPort("1.2.3.4:1"), netip.MustParseAddrPort("1.2.3.4:2")},
Notes: []string{"没有服务器支持 CHANGE-REQUEST"},
Results: []netdiag.STUNResult{
{Server: "stun.qq.com:3478", Name: "腾讯", Region: netdiag.RegionCN, OK: true, RTT: 9 * time.Millisecond, Mapped: netip.MustParseAddrPort("1.2.3.4:1")},
{Server: "stun.cloudflare.com:3478", Region: netdiag.RegionIntl, Err: "no response"},
},
},
PortMap: netdiag.PortMapReport{
Status: netdiag.StatusWarn, Gateway: netip.MustParseAddr("192.168.1.1"),
UPnP: netdiag.ServiceProbe{Available: true, Detail: "Archer AX73 (TP-Link)", ExternalIP: netip.MustParseAddr("1.2.3.4")},
NATPMP: netdiag.ServiceProbe{Err: "timeout"},
PCP: netdiag.ServiceProbe{Err: "timeout"},
},
Overseas: netdiag.OverseasReport{
Status: netdiag.StatusWarn, Summary: "境外不可达",
Probes: []netdiag.ReachProbe{
{Name: "cf", URL: "https://cp.cloudflare.com/generate_204", Region: netdiag.RegionIntl, Network: "tcp4", Err: "timeout"},
{Name: "baidu", URL: "https://www.baidu.com", Region: netdiag.RegionCN, OK: true, StatusCode: 200, RTT: 30 * time.Millisecond},
},
},
Egress: netdiag.EgressReport{
Status: netdiag.StatusWarn, Divergent: true, Summary: "出口 IP 不一致",
UniqueIPs: []netip.Addr{netip.MustParseAddr("1.2.3.4"), netip.MustParseAddr("5.6.7.8")},
Observations: []netdiag.EgressObservation{
{Method: netdiag.MethodSTUN, Source: "stun.qq.com:3478", Region: netdiag.RegionCN, IP: netip.MustParseAddr("1.2.3.4")},
{Method: netdiag.MethodHTTPProxy, Source: "https://api.ipify.org", Region: netdiag.RegionIntl, IP: netip.MustParseAddr("5.6.7.8")},
{Method: netdiag.MethodHTTPv6, Source: "https://6.ipw.cn", Region: netdiag.RegionCN, Err: "no ipv6"},
},
Geo: []netdiag.GeoInfo{
{IP: netip.MustParseAddr("1.2.3.4"), Country: "CN", City: "Shanghai", ASN: "AS4134", Org: "Chinanet", Provider: "ipinfo.io"},
{IP: netip.MustParseAddr("5.6.7.8"), Err: "lookup failed"},
},
Countries: []string{"CN", "JP"},
},
Tailscale: netdiag.TailscaleReport{
Available: true, UDP: true, IPv4: true, Status: netdiag.StatusOK,
Summary: "首选 DERP tok", PreferredDERP: "tok",
MappingVariesByDestIP: &yes,
DERP: []netdiag.DERPLatency{
{RegionID: 1, RegionCode: "tok", Name: "Tokyo", Latency: 40 * time.Millisecond, Preferred: true},
{RegionID: 2, RegionCode: "sin", Name: "Singapore", Latency: 90 * time.Millisecond},
},
},
}
a.diag.report = rep
a.diag.lastRun = time.Now()
for _, size := range []image.Point{{X: 1200, Y: 800}, {X: 880, Y: 560}} {
gtx, _ := newTestContext(size)
st := readyState(t)
for i := 0; i < 2; i++ {
a.shell(gtx, st)
}
}
// The report must also render as shareable text without panicking.
if got := rep.Text(); got == "" {
t.Fatal("Report.Text returned empty")
}
}
func TestFormatHelpers(t *testing.T) {
cases := []struct {
got, want string
}{
{FormatLatency(0), "—"},
{FormatLatency(1500 * time.Microsecond), "1.5 ms"},
{FormatLatency(42 * time.Millisecond), "42 ms"},
{FormatLatency(2500 * time.Millisecond), "2.5 s"},
{FormatBytes(0), "0 B"},
{FormatBytes(2048), "2 KiB"},
{FormatBytes(5 * 1024 * 1024), "5 MiB"},
{FormatDuration(90 * time.Second), "1m 30s"},
{FormatDuration(3 * time.Hour), "3h 0m"},
{Truncate("abcdef", 4), "abc…"},
{Truncate("ab", 4), "ab"},
}
for i, c := range cases {
if c.got != c.want {
t.Errorf("case %d: got %q want %q", i, c.got, c.want)
}
}
}
func TestTrFallsBackToEnglish(t *testing.T) {
if Tr(LangEN, KNavPeers) != "Peers" {
t.Errorf("english lookup failed")
}
if Tr(LangZH, KNavPeers) != "节点" {
t.Errorf("chinese lookup failed")
}
if Tr(LangZH, Key(-1)) != "?" {
t.Errorf("out-of-range key should not panic or return empty")
}
// Every key must resolve in both languages; a missing entry would render
// as a bare "?" in the UI.
for k := Key(0); k < kCount; k++ {
if Tr(LangEN, k) == "?" {
t.Errorf("key %d has no english string", k)
}
if Tr(LangZH, k) == "?" {
t.Errorf("key %d has no chinese string", k)
}
}
}
// TestGrowOnce guards the window-resize latch. Growing more than once would
// snap a window the user had deliberately resized back to the shell default
// every time the service restarted.
func TestGrowOnce(t *testing.T) {
a := testApp(t)
// A Window with no driver queues options instead of touching a display,
// which is what makes this testable without one.
w := new(app.Window)
if a.grown.Load() {
t.Fatal("a fresh App must not be marked grown")
}
a.grow(w)
if !a.grown.Load() {
t.Fatal("grow() must latch")
}
// Must be a no-op now.
a.grow(w)
a.grow(w)
}
// TestStatTilesUniformHeight guards the overview's top row. The tiles sit in a
// Flex, which does not equalise child heights, so anything that makes one tile
// taller — a wrapped value, a hint line present on some tiles but not others —
// visibly misaligns the row. Narrow widths are the interesting case: that is
// where "19 / 25" wraps and "8" does not.
func TestStatTilesUniformHeight(t *testing.T) {
a := testApp(t)
tiles := []struct{ value, label, hint string }{
{"19 / 25", "在线节点", "3 已关联"},
{"8", "本机服务", "5 已广播"},
{"8 / 0", "连接规则 / 转发规则", ""},
{"10s", "运行时长", ""},
}
for _, w := range []int{60, 80, 100, 140, 200, 300} {
var first int
for i, c := range tiles {
gtx, _ := newTestContext(image.Pt(w, 400))
gtx.Constraints.Min = image.Point{}
h := a.overview.statTile(a, gtx, c.value, c.label, c.hint, LevelNeutral, IconNodes).Size.Y
if i == 0 {
first = h
continue
}
if h != first {
t.Errorf("width=%d: tile %q is %dpx, tile %q is %dpx — the row must be flush",
w, c.label, h, tiles[0].label, first)
}
}
}
}