feat(ui): контроллер — подписчик шин и мост к Fyne-представлению
- View — интерфейс Fyne-слоя: колбэки на все доменные события + логи. - Controller подписывается на доменную и логовую шины (через events.Hub) и диспатчит события в View; колбэки выполняются в горутинах Hub, поэтому реализация Fyne обязана обновлять виджеты через fyne.Do (спец 12.4). - Close отписывает контроллер (сворачивание окна) без остановки Core. - NilView — no-op для headless (--noui) и тестов. - Юнит-тесты: доставка доменных/логовых событий, остановка после Close.
This commit is contained in:
52
internal/ui/controller.go
Normal file
52
internal/ui/controller.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/kamelion/ratatoskr-go/internal/events"
|
||||
)
|
||||
|
||||
// Controller — подписчик шин событий, мост к Fyne-представлению.
|
||||
//
|
||||
// Регистрирует Hub'ы на доменной и логовой шинах и диспатчит события в View
|
||||
// (см. спец 12.4): колбэки View выполняются в горутинах Hub, поэтому
|
||||
// реализация Fyne должна вызывать fyne.Do, чтобы обновить виджеты на главной
|
||||
// горутине окна.
|
||||
//
|
||||
// Жизненный цикл независим от ядра: Close только отписывает Controller от шин
|
||||
// (например, при сворачивании окна), но Core/worker продолжают работать.
|
||||
type Controller struct {
|
||||
domain *events.Hub
|
||||
logs *events.Hub
|
||||
view View
|
||||
}
|
||||
|
||||
// New создаёт Controller, подписанный на доменную (domainBus) и логовую
|
||||
// (logBus) шины, с представлением view.
|
||||
func New(domainBus *events.Bus, logBus *events.LogBus, view View) *Controller {
|
||||
c := &Controller{view: view}
|
||||
|
||||
c.domain = events.NewHub(domainBus)
|
||||
events.On(c.domain, func(e events.TaskCreated) { view.OnTaskCreated(e) })
|
||||
events.On(c.domain, func(e events.TaskUpdated) { view.OnTaskUpdated(e) })
|
||||
events.On(c.domain, func(e events.TaskDeleted) { view.OnTaskDeleted(e) })
|
||||
events.On(c.domain, func(e events.TaskStatusChanged) { view.OnTaskStatusChanged(e) })
|
||||
events.On(c.domain, func(e events.HistoryAppended) { view.OnHistoryAppended(e) })
|
||||
events.On(c.domain, func(e events.TraceAppended) { view.OnTraceAppended(e) })
|
||||
events.On(c.domain, func(e events.AgentActivity) { view.OnAgentActivity(e) })
|
||||
|
||||
c.logs = events.NewHub(logBus.Bus)
|
||||
events.On(c.logs, func(e events.LogLine) { view.OnLog(e) })
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// Start запускает подписку на обе шины.
|
||||
func (c *Controller) Start() {
|
||||
c.domain.Start()
|
||||
c.logs.Start()
|
||||
}
|
||||
|
||||
// Close останавливает подписки. Идемпотентен.
|
||||
func (c *Controller) Close() {
|
||||
c.domain.Close()
|
||||
c.logs.Close()
|
||||
}
|
||||
146
internal/ui/controller_test.go
Normal file
146
internal/ui/controller_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
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() // не должно падать
|
||||
}
|
||||
40
internal/ui/view.go
Normal file
40
internal/ui/view.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Package ui — контроллер и представления десктопного интерфейса (Fyne).
|
||||
//
|
||||
// Слой обмена с ядром: однонаправленный поток (спец 12.2–12.5).
|
||||
// - Core мутирует состояние; UI только читает снимки и реагирует на события.
|
||||
// - Действия UI = команды (CreateTask/ApproveTask/...), которые зовут Core.
|
||||
// - Подписчик шины (Controller) получает события и перекладывает их в View
|
||||
// (реализация Fyne) через fyne.Do — никаких прямых вызовов Fyne из core.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/kamelion/ratatoskr-go/internal/events"
|
||||
)
|
||||
|
||||
// View — интерфейс, который реализует Fyne-слой окна.
|
||||
//
|
||||
// Методы вызываются из горутины Controller (горутина Hub-подписчика), поэтому
|
||||
// реализация обязана перекладывать работу на поток Fyne через fyne.Do /
|
||||
// fyne.DoAndWait, либо использовать thread-safe структуры (binding).
|
||||
type View interface {
|
||||
OnTaskCreated(e events.TaskCreated)
|
||||
OnTaskUpdated(e events.TaskUpdated)
|
||||
OnTaskDeleted(e events.TaskDeleted)
|
||||
OnTaskStatusChanged(e events.TaskStatusChanged)
|
||||
OnHistoryAppended(e events.HistoryAppended)
|
||||
OnTraceAppended(e events.TraceAppended)
|
||||
OnAgentActivity(e events.AgentActivity)
|
||||
OnLog(e events.LogLine)
|
||||
}
|
||||
|
||||
// NilView — no-op реализация View для headless-режима и тестов.
|
||||
type NilView struct{}
|
||||
|
||||
func (NilView) OnTaskCreated(events.TaskCreated) {}
|
||||
func (NilView) OnTaskUpdated(events.TaskUpdated) {}
|
||||
func (NilView) OnTaskDeleted(events.TaskDeleted) {}
|
||||
func (NilView) OnTaskStatusChanged(events.TaskStatusChanged) {}
|
||||
func (NilView) OnHistoryAppended(events.HistoryAppended) {}
|
||||
func (NilView) OnTraceAppended(events.TraceAppended) {}
|
||||
func (NilView) OnAgentActivity(events.AgentActivity) {}
|
||||
func (NilView) OnLog(events.LogLine) {}
|
||||
Reference in New Issue
Block a user