Files
ratatoskr-go/internal/ui/controller_test.go
ki.sagidullin c2272137b3
Some checks failed
CI / test (pull_request) Failing after 1m19s
CI / build-and-package (amd64, linux) (pull_request) Failing after 58s
CI / build-and-package (amd64, windows) (pull_request) Successful in 53s
feat(ui): команды, snapshots, Fyne-окно и интеграция (--noui)
Команды (спец 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).
2026-08-20 08:54:55 +05:00

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() // не должно падать
}