Команды (спец 12.2): - ui.Commands: действия UI → текстовые команды канала (start/cancel/skip/ retry/continue/approve/send), единый путь через chat.Channel. - ui.Window = chat.Channel + View; NilWindow для headless/тестов. Snapshots (спец 12.5): - ui.Store: чтение-модель, возвращает только копии (ListTasks/GetTask/ GetHistory/GetTraces); ui.DBStore поверх storage. Fyne-окно (internal/ui/desktop, build-tag cgo): - список задач слева, сплиты рабочей области и панели «Логи»/«Состояние», ввод+кнопки команд, тёмная тема, fullscreen, сохранение layout в Preferences, сворачивание при закрытии крестиком. - Колбэки View через fyne.Do (спец 12.4). Интеграция: - app.New(..., noUI); флаг --noui; UI собирается только с cgo (ui_cgo/ ui_noui фабрики), приложение headless без него. - Run: окно блокирует главную горутину; «Завершить» → cancel → graceful shutdown (спец 12.6). - go.mod: fyne.io/fyne/v2 v2.6.0 (direct).
147 lines
3.8 KiB
Go
147 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() // не должно падать
|
|
}
|