package browsercfg import ( "errors" "path/filepath" "testing" "time" ) // TestConfigNormalizeFillsDefaults 钉住「零值 + Normalize = 全默认」(design/28 §8 B1: // 代码只提供默认值)。Config 全字段可比较,故整struct 比对。 func TestConfigNormalizeFillsDefaults(t *testing.T) { got := Config{}.Normalize("/data") want := Config{ Headless: DefaultHeadless, ProfileDir: filepath.Join("/data", "browser", "profile"), ViewportWidth: DefaultViewportWidth, ViewportHeight: DefaultViewportHeight, TimeoutSeconds: DefaultTimeoutSeconds, IdleTimeoutSeconds: DefaultIdleTimeoutSeconds, Concurrency: DefaultConcurrency, ScreenshotMaxBytes: DefaultScreenshotMaxBytes, } if got != want { t.Fatalf("Normalize 默认值回落不符\n got=%+v\nwant=%+v", got, want) } if got.Headless != false { t.Fatalf("默认档必须是有头(design/28 §2),got headless=%v", got.Headless) } } // TestConfigNormalizeKeepsExplicit 钉住「显式值不被默认覆盖」。 func TestConfigNormalizeKeepsExplicit(t *testing.T) { in := Config{ Headless: true, Executable: "/opt/chrome", CDPEndpoint: "http://127.0.0.1:9333", ProfileDir: "/tmp/p", ViewportWidth: 1920, ViewportHeight: 1080, TimeoutSeconds: 5, IdleTimeoutSeconds: 60, Concurrency: 1, ScreenshotMaxBytes: 1 << 20, } if got := in.Normalize("/data"); got != in { t.Fatalf("Normalize 改动了显式值\n got=%+v\nwant=%+v", got, in) } } // TestConfigNormalizeNonPositiveUsesDefault 钉住「≤0 = 用默认」(含负数)。 func TestConfigNormalizeNonPositiveUsesDefault(t *testing.T) { in := Config{ ViewportWidth: -1, ViewportHeight: 0, TimeoutSeconds: -30, IdleTimeoutSeconds: 0, Concurrency: -2, ScreenshotMaxBytes: -5, } got := in.Normalize("/data") if got.ViewportWidth != DefaultViewportWidth || got.ViewportHeight != DefaultViewportHeight { t.Fatalf("视口未回落默认:%d×%d", got.ViewportWidth, got.ViewportHeight) } if got.TimeoutSeconds != DefaultTimeoutSeconds || got.IdleTimeoutSeconds != DefaultIdleTimeoutSeconds { t.Fatalf("超时未回落默认:timeout=%d idle=%d", got.TimeoutSeconds, got.IdleTimeoutSeconds) } if got.Concurrency != DefaultConcurrency { t.Fatalf("并发未回落默认:%d", got.Concurrency) } if got.ScreenshotMaxBytes != DefaultScreenshotMaxBytes { t.Fatalf("截图字节上限未回落默认:%d", got.ScreenshotMaxBytes) } } // TestConfigNormalizeClampsConcurrency 钉住并发上限恒为 1(design/28 §3、§6 坑 2:profile 锁)。 func TestConfigNormalizeClampsConcurrency(t *testing.T) { for _, in := range []int{2, 8, 100} { if got := (Config{Concurrency: in}).Normalize("/data").Concurrency; got != DefaultConcurrency { t.Fatalf("Concurrency=%d 未被夹到 %d,got=%d", in, DefaultConcurrency, got) } } } // TestDefaultsMatchesZeroNormalize 钉住 Defaults 与「零值 Normalize」是同一件事。 func TestDefaultsMatchesZeroNormalize(t *testing.T) { if got, want := Defaults("/d"), (Config{}).Normalize("/d"); got != want { t.Fatalf("Defaults 与 Normalize 口径不一致\n got=%+v\nwant=%+v", got, want) } } // TestConfigValidateAcceptsDefaults 钉住「全默认配置合法」——Validate 判的是原始值, // 零值代表「用默认」,不该被判非法。 func TestConfigValidateAcceptsDefaults(t *testing.T) { if err := (Config{}).Validate(); err != nil { t.Fatalf("零值配置应合法(= 全默认),got err=%v", err) } } // TestConfigValidateRejectsOverCaps 钉住硬上限(design/28 §3:视口尺寸上限 + 单张字节上限)。 func TestConfigValidateRejectsOverCaps(t *testing.T) { cases := []struct { name string cfg Config }{ {"视口宽超上限", Config{ViewportWidth: MaxViewportWidth + 1}}, {"视口高超上限", Config{ViewportHeight: MaxViewportHeight + 1}}, {"截图字节超上限", Config{ScreenshotMaxBytes: MaxScreenshotMaxBytes + 1}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { err := tc.cfg.Validate() if !errors.Is(err, ErrInvalidConfig) { t.Fatalf("应返回 ErrInvalidConfig,got=%v", err) } }) } } // TestConfigValidateAcceptsAtCaps 钉住边界值(正好等于上限)合法,防止把 > 写成 >=。 func TestConfigValidateAcceptsAtCaps(t *testing.T) { cfg := Config{ ViewportWidth: MaxViewportWidth, ViewportHeight: MaxViewportHeight, ScreenshotMaxBytes: MaxScreenshotMaxBytes, } if err := cfg.Validate(); err != nil { t.Fatalf("边界值应合法,got err=%v", err) } } // TestConfigValidateEndpoint 是 cdpEndpoint 合法性的表驱动:非 http(s) 绝对地址一律非法 // (照 config.ResolveEndpoint 口径),合法与空(=交给探测)放行。 func TestConfigValidateEndpoint(t *testing.T) { cases := []struct { endpoint string wantErr bool }{ {"", false}, {" ", false}, {"http://127.0.0.1:9222", false}, {"https://browser.internal:9222", false}, {"ftp://127.0.0.1:9222", true}, {"127.0.0.1:9222", true}, {"ws://127.0.0.1:9222", true}, {"http://", true}, } for _, tc := range cases { err := (Config{CDPEndpoint: tc.endpoint}).Validate() if tc.wantErr && !errors.Is(err, ErrInvalidConfig) { t.Fatalf("endpoint=%q 应判非法,got err=%v", tc.endpoint, err) } if !tc.wantErr && err != nil { t.Fatalf("endpoint=%q 应合法,got err=%v", tc.endpoint, err) } } } // TestConfigTimeoutAccessors 钉住秒数字段到 time.Duration 的换算。 func TestConfigTimeoutAccessors(t *testing.T) { cfg := Config{TimeoutSeconds: 12, IdleTimeoutSeconds: 90} if got := cfg.Timeout(); got != 12*time.Second { t.Fatalf("Timeout()=%v,want %v", got, 12*time.Second) } if got := cfg.IdleTimeout(); got != 90*time.Second { t.Fatalf("IdleTimeout()=%v,want %v", got, 90*time.Second) } } // TestDefaultPaths 钉住三个落点(design/13 §2.1 + design/28 §1 M2/§4)。 func TestDefaultPaths(t *testing.T) { data := filepath.Join("root", "data") if got, want := DefaultConfigPath(data), filepath.Join(data, "config", "plugins", "browser.json"); got != want { t.Fatalf("DefaultConfigPath=%q,want %q", got, want) } if got, want := DefaultProfileDir(data), filepath.Join(data, "browser", "profile"); got != want { t.Fatalf("DefaultProfileDir=%q,want %q", got, want) } if got, want := DefaultShotsDir(data), filepath.Join(data, "browser", "shots"); got != want { t.Fatalf("DefaultShotsDir=%q,want %q", got, want) } } // TestReasonMessage 钉住分类原因码都有中文文案(上层直接写日志,避免两处措辞漂移)。 func TestReasonMessage(t *testing.T) { for _, r := range []Reason{ReasonUsable, ReasonExecutableMissing, ReasonCDPUnreachable, ReasonNoSource} { if r.Message() == "" { t.Fatalf("原因码 %q 缺中文文案", r) } } if got := Reason("unknown").Message(); got != "" { t.Fatalf("未知原因码应返回空串,got=%q", got) } }