Files
cc-connect/tests/blackbox/platform/mock_platform.go
T
2026-06-02 23:14:41 +08:00

337 lines
9.6 KiB
Go

// Package platform provides the MockPlatform used in blackbox tests.
//
// MockPlatform implements core.Platform without connecting to any real IM
// service. Tests inject messages via InjectMessage and verify cc-connect's
// output via WaitForReply / GetSentMessages.
package platform
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/chenhg5/cc-connect/core"
)
// SentMessage captures a single outbound call from cc-connect to the platform.
type SentMessage struct {
Content string
Card *core.Card // non-nil when SendCard/ReplyCard was called
ReplyCtx any
At time.Time
}
// Text returns the displayable text of the message (card fallback if needed).
func (m *SentMessage) Text() string {
if m.Content != "" {
return m.Content
}
if m.Card != nil {
return m.Card.RenderText()
}
return ""
}
// MockReplyCtx is a simple reply context stored in injected messages.
type MockReplyCtx struct {
Platform string
ChatID string
UserID string
}
// MockPlatform implements core.Platform for blackbox tests.
// Messages are injected via InjectMessage; outbound calls (Reply, Send,
// SendCard, etc.) are recorded and retrievable for assertions.
type MockPlatform struct {
mu sync.Mutex
name string
handler core.MessageHandler
sent []*SentMessage
notify chan struct{}
}
// New creates a named MockPlatform ready for use in tests.
func New(name string) *MockPlatform {
return &MockPlatform{
name: name,
notify: make(chan struct{}, 1),
}
}
// ── core.Platform interface ──────────────────────────────────────────────────
func (p *MockPlatform) Name() string { return p.name }
func (p *MockPlatform) Start(handler core.MessageHandler) error {
p.handler = handler
return nil
}
func (p *MockPlatform) Stop() error { return nil }
func (p *MockPlatform) Reply(ctx context.Context, replyCtx any, content string) error {
p.record(&SentMessage{Content: content, ReplyCtx: replyCtx, At: time.Now()})
return nil
}
func (p *MockPlatform) Send(ctx context.Context, replyCtx any, content string) error {
return p.Reply(ctx, replyCtx, content)
}
// ── Optional platform interfaces ─────────────────────────────────────────────
func (p *MockPlatform) SendCard(ctx context.Context, replyCtx any, card *core.Card) error {
p.record(&SentMessage{Content: card.RenderText(), Card: card, ReplyCtx: replyCtx, At: time.Now()})
return nil
}
func (p *MockPlatform) ReplyCard(ctx context.Context, replyCtx any, card *core.Card) error {
return p.SendCard(ctx, replyCtx, card)
}
func (p *MockPlatform) SendWithButtons(ctx context.Context, replyCtx any, content string, buttons [][]core.ButtonOption) error {
return p.Reply(ctx, replyCtx, content)
}
func (p *MockPlatform) SendImage(ctx context.Context, replyCtx any, img core.ImageAttachment) error {
p.record(&SentMessage{Content: "[image]", ReplyCtx: replyCtx, At: time.Now()})
return nil
}
func (p *MockPlatform) SendFile(ctx context.Context, replyCtx any, file core.FileAttachment) error {
p.record(&SentMessage{Content: fmt.Sprintf("[file: %s]", file.FileName), ReplyCtx: replyCtx, At: time.Now()})
return nil
}
// ── Message injection ────────────────────────────────────────────────────────
// InjectMessage simulates a user sending a plain-text message to cc-connect.
// userID and chatID form the session key; each unique (userID, chatID) pair is
// an independent session.
func (p *MockPlatform) InjectMessage(userID, chatID, content string) {
p.InjectMessageWithAttachments(userID, chatID, content, nil, nil)
}
// InjectMessageWithAttachments simulates a user sending a message with attachments.
func (p *MockPlatform) InjectMessageWithAttachments(
userID, chatID, content string,
images []core.ImageAttachment,
files []core.FileAttachment,
) {
msg := &core.Message{
Platform: p.name,
SessionKey: fmt.Sprintf("%s:%s:%s", p.name, chatID, userID),
MessageID: fmt.Sprintf("msg_%d_%s", time.Now().UnixNano(), userID),
UserID: userID,
UserName: "test-user-" + userID,
ChatName: chatID,
Content: content,
Images: images,
Files: files,
ReplyCtx: &MockReplyCtx{Platform: p.name, ChatID: chatID, UserID: userID},
}
// Run in goroutine, matching how real platforms deliver messages.
go p.handler(p, msg)
}
// ── Assertion helpers ────────────────────────────────────────────────────────
// WaitForTurnComplete waits until the message stream has been "stable" —
// no new messages — for at least idlePeriod, then returns all messages
// received since startIdx. This is the right way to wait for a full agent
// turn when cc-connect may send multiple messages per turn (thinking indicators,
// progress updates, then the final reply).
//
// A good idlePeriod for real agents is 3-5s; set timeout to your test deadline.
func (p *MockPlatform) WaitForTurnComplete(startIdx int, idlePeriod, timeout time.Duration) []*SentMessage {
deadline := time.Now().Add(timeout)
// We must first receive at least one message after startIdx.
if !func() bool {
for {
p.mu.Lock()
if len(p.sent) > startIdx {
p.mu.Unlock()
return true
}
p.mu.Unlock()
remaining := time.Until(deadline)
if remaining <= 0 {
return false
}
select {
case <-p.notify:
case <-time.After(remaining):
}
}
}() {
return nil
}
// Now wait for stability: no new messages for idlePeriod.
for {
lastCount := p.MessageCount()
wait := idlePeriod
if time.Until(deadline) < wait {
wait = time.Until(deadline)
}
if wait <= 0 {
break
}
select {
case <-p.notify:
// New message arrived — reset stability window.
continue
case <-time.After(wait):
}
// Check if count changed during the wait (notify is non-blocking so
// we might have missed a signal).
if p.MessageCount() == lastCount {
break // stable
}
}
p.mu.Lock()
out := append([]*SentMessage(nil), p.sent[startIdx:]...)
p.mu.Unlock()
return out
}
// WaitForReply waits until at least one new message is sent after startIdx,
// then returns it. Returns nil on timeout.
func (p *MockPlatform) WaitForReply(startIdx int, timeout time.Duration) *SentMessage {
deadline := time.Now().Add(timeout)
for {
p.mu.Lock()
if len(p.sent) > startIdx {
msg := p.sent[startIdx]
p.mu.Unlock()
return msg
}
p.mu.Unlock()
remaining := time.Until(deadline)
if remaining <= 0 {
return nil
}
// Block until a new message arrives or timeout.
select {
case <-p.notify:
case <-time.After(remaining):
}
}
}
// WaitForN waits until at least n messages have been sent in total.
// Returns the current slice when satisfied, or times out.
func (p *MockPlatform) WaitForN(n int, timeout time.Duration) ([]*SentMessage, bool) {
deadline := time.Now().Add(timeout)
for {
p.mu.Lock()
if len(p.sent) >= n {
out := append([]*SentMessage(nil), p.sent...)
p.mu.Unlock()
return out, true
}
p.mu.Unlock()
remaining := time.Until(deadline)
if remaining <= 0 {
return p.GetSentMessages(), false
}
select {
case <-p.notify:
case <-time.After(remaining):
}
}
}
// WaitForMessageContaining waits until any message (starting from startIdx)
// contains the given substring (case-insensitive). Returns the matching message
// or nil on timeout.
func (p *MockPlatform) WaitForMessageContaining(startIdx int, substr string, timeout time.Duration) *SentMessage {
deadline := time.Now().Add(timeout)
for {
p.mu.Lock()
for i := startIdx; i < len(p.sent); i++ {
if strings.Contains(strings.ToLower(p.sent[i].Text()), strings.ToLower(substr)) {
msg := p.sent[i]
p.mu.Unlock()
return msg
}
}
p.mu.Unlock()
remaining := time.Until(deadline)
if remaining <= 0 {
return nil
}
select {
case <-p.notify:
case <-time.After(remaining):
}
}
}
// MessageCount returns the current number of sent messages.
func (p *MockPlatform) MessageCount() int {
p.mu.Lock()
defer p.mu.Unlock()
return len(p.sent)
}
// GetSentMessages returns a snapshot of all sent messages.
func (p *MockPlatform) GetSentMessages() []*SentMessage {
p.mu.Lock()
defer p.mu.Unlock()
return append([]*SentMessage(nil), p.sent...)
}
// GetLastMessage returns the most recent sent message, or nil if none.
func (p *MockPlatform) GetLastMessage() *SentMessage {
p.mu.Lock()
defer p.mu.Unlock()
if len(p.sent) == 0 {
return nil
}
return p.sent[len(p.sent)-1]
}
// AllText returns all sent message texts joined by newline, useful for
// debugging failed assertions.
func (p *MockPlatform) AllText() string {
p.mu.Lock()
defer p.mu.Unlock()
parts := make([]string, len(p.sent))
for i, m := range p.sent {
parts[i] = m.Text()
}
return strings.Join(parts, "\n---\n")
}
// Reset clears all recorded messages. Use between sub-scenarios within one
// test where you need a clean slate.
func (p *MockPlatform) Reset() {
p.mu.Lock()
defer p.mu.Unlock()
p.sent = nil
}
// ── internal ─────────────────────────────────────────────────────────────────
func (p *MockPlatform) record(m *SentMessage) {
p.mu.Lock()
p.sent = append(p.sent, m)
p.mu.Unlock()
// Non-blocking notify: if a waiter is already queued, it will pick up the
// message on its next iteration.
select {
case p.notify <- struct{}{}:
default:
}
}