From baf2ad7147589be56fd2c8e93f23429b95b5b2dd Mon Sep 17 00:00:00 2001 From: "ki.sagidullin" Date: Thu, 20 Aug 2026 08:15:42 +0500 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BB=D0=BB=D0=B5=D1=80=20=E2=80=94=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D1=87=D0=B8=D0=BA=20=D1=88=D0=B8=D0=BD=20?= =?UTF-8?q?=D0=B8=20=D0=BC=D0=BE=D1=81=D1=82=20=D0=BA=20Fyne-=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D1=81=D1=82=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - View — интерфейс Fyne-слоя: колбэки на все доменные события + логи. - Controller подписывается на доменную и логовую шины (через events.Hub) и диспатчит события в View; колбэки выполняются в горутинах Hub, поэтому реализация Fyne обязана обновлять виджеты через fyne.Do (спец 12.4). - Close отписывает контроллер (сворачивание окна) без остановки Core. - NilView — no-op для headless (--noui) и тестов. - Юнит-тесты: доставка доменных/логовых событий, остановка после Close. --- internal/ui/controller.go | 52 ++++++++++++ internal/ui/controller_test.go | 146 +++++++++++++++++++++++++++++++++ internal/ui/view.go | 40 +++++++++ 3 files changed, 238 insertions(+) create mode 100644 internal/ui/controller.go create mode 100644 internal/ui/controller_test.go create mode 100644 internal/ui/view.go diff --git a/internal/ui/controller.go b/internal/ui/controller.go new file mode 100644 index 0000000..b53f606 --- /dev/null +++ b/internal/ui/controller.go @@ -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() +} \ No newline at end of file diff --git a/internal/ui/controller_test.go b/internal/ui/controller_test.go new file mode 100644 index 0000000..67df3f9 --- /dev/null +++ b/internal/ui/controller_test.go @@ -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() // не должно падать +} \ No newline at end of file diff --git a/internal/ui/view.go b/internal/ui/view.go new file mode 100644 index 0000000..38df43c --- /dev/null +++ b/internal/ui/view.go @@ -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) {} \ No newline at end of file