// 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: } }