初始化仓库
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
// 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:
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user