package browser // 本文件钉住 B3 闭环的**浏览器侧**(design/28 §7 判据 2、切片 Y4):截图落盘后被放进 // 「本回合待送出」的附件箱,且**按会话隔离、取走即消费、有界**;没有会话 id 时如实报 // no-session 而不是丢进一个谁都能拿的公共格子。 // // 视觉侧(图怎么变成 image 部件 / vision 工具文字)由 // `internal/runtime/multimodal/vision/shot_test.go` 覆盖,两处不重复。 import ( "context" "strings" "testing" "time" browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser" ) // fakeShot 造一张与落盘结论同形的截图(本文件不碰真浏览器)。 func fakeShot(path string) browsercap.Shot { return browsercap.Shot{Path: path, Bytes: 1024, SHA256: "abc", Format: "png", Width: 1280, Height: 800, DimensionsKnown: true} } // TestShotOutbox_PerSessionAndDrainOnce 钉住「按会话隔离 + 取走即消费」: // A 会话的截图绝不出现在 B 会话的取用结果里,且同一张只能被送出一次。 func TestShotOutbox_PerSessionAndDrainOnce(t *testing.T) { t.Parallel() box := NewShotOutbox() if got := box.Put("sess-a", fakeShot("/a.png")); got != TurnAttachmentQueued { t.Fatalf("Put = %q, want %q", got, TurnAttachmentQueued) } if n := box.Pending("sess-b"); n != 0 { t.Fatalf("B 会话不该看到 A 的截图,Pending=%d", n) } if got := box.Drain("sess-b"); got != nil { t.Fatalf("B 会话 Drain = %v, want nil", got) } drained := box.Drain("sess-a") if len(drained) != 1 || drained[0].Path != "/a.png" { t.Fatalf("Drain = %+v", drained) } if again := box.Drain("sess-a"); again != nil { t.Fatalf("第二次 Drain 应当为空(取走即消费):%+v", again) } } // TestShotOutbox_NoSessionIsHonest 钉住「拿不到会话 id 就不入箱」:子代理 / 未接编排器的 // 调用必须如实报 no-session(图仍在盘上,模型按路径自己读),而不是丢进一个全局格子。 // // ⚠️ 这里只测「没有会话」这一档:把会话 id 播种到 ctx 上的**唯一**生产者是 // `loop.Run`(`withLoopSession` 不导出),因此「有会话时真的入箱」由组合根的真实循环 // 用例覆盖(`cmd/cyrene/wire_browser_context_test.go`),本包不伪造那条通道。 func TestShotOutbox_NoSessionIsHonest(t *testing.T) { t.Parallel() box := NewShotOutbox() if got := box.Put("", fakeShot("/x.png")); got != TurnAttachmentNoSession { t.Fatalf("空会话 id 的 Put = %q, want %q", got, TurnAttachmentNoSession) } if got := box.Put(" ", fakeShot("/x.png")); got != TurnAttachmentNoSession { t.Fatalf("空白会话 id 的 Put = %q, want %q", got, TurnAttachmentNoSession) } if got := box.PutForContext(context.Background(), fakeShot("/x.png")); got != TurnAttachmentNoSession { t.Fatalf("ctx 上没有会话时的 PutForContext = %q, want %q", got, TurnAttachmentNoSession) } if n := box.Pending(""); n != 0 { t.Fatalf("空会话不该攒下任何东西:Pending=%d", n) } } // TestShotOutbox_BoundedAndTTL 钉住有界与寿命:超过上限丢**最旧**的一张并如实报 // outbox-full;超过 TTL 的截图不再送出(会话可能早已结束)。 func TestShotOutbox_BoundedAndTTL(t *testing.T) { t.Parallel() box := NewShotOutbox() base := time.Now() box.now = func() time.Time { return base } for i := 0; i < maxPendingShots; i++ { if got := box.Put("s", fakeShot(string(rune('a'+i)))); got != TurnAttachmentQueued { t.Fatalf("第 %d 张 Put = %q", i, got) } } if got := box.Put("s", fakeShot("overflow")); got != TurnAttachmentFull { t.Fatalf("超上限的 Put = %q, want %q", got, TurnAttachmentFull) } got := box.Drain("s") if len(got) != maxPendingShots { t.Fatalf("攒着的张数 = %d, want %d", len(got), maxPendingShots) } if got[len(got)-1].Path != "overflow" { t.Errorf("最新的一张应当留下:%+v", got) } // TTL:过期的格子被清掉(文件仍在盘上,只是不再送出)。 box.Put("t", fakeShot("/old.png")) box.now = func() time.Time { return base.Add(shotOutboxTTL + time.Minute) } if n := box.Pending("t"); n != 0 { t.Errorf("过期截图仍在箱里:Pending=%d", n) } } // TestTool_ScreenshotQueuesShotForTheTurn 钉住工具面的接线:带会话 ctx 的截图动作把图 // 放进箱子并如实报 queued,同时**仍然**返回路径与统计(模型/面板都能按路径再读)。 // // 会话来自 `loop.Run`(本包造不出来),因此这里经**箱子本身**验证注入点:工具用的是 // 同一个箱子实例(`WithShotOutbox` 注入 → `ShotOutbox()` 取回),真实循环链路由组合根 // 用例端到端覆盖。 func TestTool_ScreenshotQueuesShotForTheTurn(t *testing.T) { t.Parallel() drv := &fakeDriver{available: true, shot: fakeShot("/shots/s1.png")} injected := NewShotOutbox() injected.Put("sess-shot", fakeShot("/preset.png")) tool := New(drv, WithShotOutbox(injected)) if tool.ShotOutbox() != injected { t.Fatal("WithShotOutbox 注入的箱子没被采用:装配钩子将取不到截图") } // 工具在**没有会话**的 ctx 下动作:图仍落盘,附件状态如实报 no-session。 res, err := tool.Execute(context.Background(), map[string]any{"action": "screenshot"}) if err != nil { t.Fatalf("Execute: %v", err) } m, ok := res.(map[string]any) if !ok { t.Fatalf("结果类型 = %T", res) } if got, _ := m["turnAttachment"].(string); got != TurnAttachmentNoSession { t.Fatalf("turnAttachment = %q, want %q", got, TurnAttachmentNoSession) } if got, _ := m["path"].(string); got != "/shots/s1.png" { t.Fatalf("path = %q", got) } if hint, _ := m["hint"].(string); hint == "" { t.Error("hint 为空:模型不知道这张图进没进上下文") } // 预先放进箱子的那张仍在(本次调用没有会话,因此没有消费它)。 if got := tool.ShotOutbox().Drain("sess-shot"); len(got) != 1 || got[0].Path != "/preset.png" { t.Fatalf("箱子里的截图 = %+v", got) } } // TestTool_ScreenshotWithoutSessionIsHonest 钉住没有会话时的行为:动作照样成功(图落盘), // 但 `turnAttachment` 如实报 no-session,且提示文案改为「按路径自己读」。 func TestTool_ScreenshotWithoutSessionIsHonest(t *testing.T) { t.Parallel() drv := &fakeDriver{available: true, shot: fakeShot("/shots/s2.png")} tool := New(drv) res, err := tool.Execute(context.Background(), map[string]any{"action": "screenshot"}) if err != nil { t.Fatalf("Execute: %v", err) } m := res.(map[string]any) if got, _ := m["turnAttachment"].(string); got != TurnAttachmentNoSession { t.Fatalf("turnAttachment = %q, want %q", got, TurnAttachmentNoSession) } if hint, _ := m["hint"].(string); hint == "" || !strings.Contains(hint, "NOT attached") { t.Errorf("没有会话时的提示必须说清「没进上下文」:%q", hint) } } // TestTool_StateExposesFactsWithoutDriver 钉住面板取数的兜底:驱动不支持事实面 // (假驱动)时返回零值快照,而不是 panic —— 面板据此显示「未知」。 func TestTool_StateExposesFactsWithoutDriver(t *testing.T) { t.Parallel() if got := New(nil).State(); got.Available { t.Errorf("nil 驱动的事实面 = %+v,应当是零值", got) } if got := (*Tool)(nil).State(); got.Available { t.Errorf("nil 工具的事实面 = %+v", got) } }