Files
ratatoskr-go/internal/ui/controller_test.go
ki.sagidullin baf2ad7147 feat(ui): контроллер — подписчик шин и мост к Fyne-представлению
- View — интерфейс Fyne-слоя: колбэки на все доменные события + логи.
- Controller подписывается на доменную и логовую шины (через events.Hub)
  и диспатчит события в View; колбэки выполняются в горутинах Hub, поэтому
  реализация Fyne обязана обновлять виджеты через fyne.Do (спец 12.4).
- Close отписывает контроллер (сворачивание окна) без остановки Core.
- NilView — no-op для headless (--noui) и тестов.
- Юнит-тесты: доставка доменных/логовых событий, остановка после Close.
2026-08-20 08:15:42 +05:00

146 lines
3.8 KiB
Go

package ui
import (
"sync"
"testing"
"time"
"github.com/kamelion/ratatoskr-go/internal/events"
"github.com/kamelion/ratatoskr-go/internal/model"
)
// recordingView фиксирует события, дошедшие до View.
type recordingView struct {
mu sync.Mutex
created int
updated int
deleted int
status []events.TaskStatusChanged
history []events.HistoryAppended
traces int
act []events.AgentActivity
logs []events.LogLine
}
func (v *recordingView) OnTaskCreated(events.TaskCreated) { v.created++ }
func (v *recordingView) OnTaskUpdated(events.TaskUpdated) { v.updated++ }
func (v *recordingView) OnTaskDeleted(events.TaskDeleted) { v.deleted++ }
func (v *recordingView) OnHistoryAppended(e events.HistoryAppended) {
v.mu.Lock()
v.history = append(v.history, e)
v.mu.Unlock()
}
func (v *recordingView) OnTraceAppended(events.TraceAppended) {
v.mu.Lock()
v.traces++
v.mu.Unlock()
}
func (v *recordingView) OnTaskStatusChanged(e events.TaskStatusChanged) {
v.mu.Lock()
v.status = append(v.status, e)
v.mu.Unlock()
}
func (v *recordingView) OnAgentActivity(e events.AgentActivity) {
v.mu.Lock()
v.act = append(v.act, e)
v.mu.Unlock()
}
func (v *recordingView) OnLog(e events.LogLine) {
v.mu.Lock()
v.logs = append(v.logs, e)
v.mu.Unlock()
}
// waitFor ждёт, пока условие не станет истинным (до 2 секунд).
func waitFor(cond func() bool) bool {
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return true
}
time.Sleep(10 * time.Millisecond)
}
return cond()
}
func TestControllerDeliversDomainEvents(t *testing.T) {
dbus := events.New(64)
lbus := events.NewLogBus(64)
view := &recordingView{}
c := New(dbus, lbus, view)
c.Start()
defer c.Close()
dbus.Publish(events.TaskStatusChanged{ID: 1, From: model.StatusReady, To: model.StatusRunning})
dbus.Publish(events.HistoryAppended{TaskID: 1, Role: "user", Content: "hello"})
dbus.Publish(events.AgentActivity{TaskID: 1, Agent: "dev", Stage: "run"})
if !waitFor(func() bool { return len(view.status) == 1 }) {
t.Fatal("TaskStatusChanged not delivered")
}
if !waitFor(func() bool { return len(view.history) == 1 }) {
t.Fatal("HistoryAppended not delivered")
}
if !waitFor(func() bool { return len(view.act) == 1 }) {
t.Fatal("AgentActivity not delivered")
}
sc := view.status[0]
if sc.ID != 1 || sc.From != model.StatusReady || sc.To != model.StatusRunning {
t.Fatalf("bad status event: %+v", sc)
}
}
func TestControllerDeliversLogEvents(t *testing.T) {
dbus := events.New(64)
lbus := events.NewLogBus(64)
view := &recordingView{}
c := New(dbus, lbus, view)
c.Start()
defer c.Close()
lbus.Bus.Publish(events.LogLine{Level: "log", Text: "worker started"})
if !waitFor(func() bool { return len(view.logs) == 1 }) {
t.Fatal("LogLine not delivered")
}
if got := view.logs[0]; got.Text != "worker started" || got.Level != "log" {
t.Fatalf("bad log event: %+v", got)
}
}
func TestControllerCloseStopsDelivery(t *testing.T) {
dbus := events.New(64)
lbus := events.NewLogBus(64)
view := &recordingView{}
c := New(dbus, lbus, view)
c.Start()
dbus.Publish(events.TaskCreated{ID: 1})
if !waitFor(func() bool { return view.created == 1 }) {
t.Fatal("initial delivery failed")
}
c.Close()
dbus.Publish(events.TaskCreated{ID: 2})
lbus.Bus.Publish(events.LogLine{Level: "log", Text: "after close"})
time.Sleep(150 * time.Millisecond)
if view.created > 1 {
t.Fatalf("TaskCreated delivered %d times after Close", view.created)
}
if len(view.logs) > 0 {
t.Fatalf("LogLine delivered %d times after Close", len(view.logs))
}
}
func TestControllerIdempotentClose(t *testing.T) {
dbus := events.New(16)
lbus := events.NewLogBus(16)
c := New(dbus, lbus, &recordingView{})
c.Start()
c.Close()
c.Close() // не должно падать
}