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