2026-01-17 16:08:53 +00:00
|
|
|
package testutil
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
|
2026-02-12 20:34:35 +00:00
|
|
|
"github.com/boolean-maybe/tiki/config"
|
2026-01-17 16:08:53 +00:00
|
|
|
"github.com/boolean-maybe/tiki/controller"
|
2026-04-09 02:46:46 +00:00
|
|
|
rukiRuntime "github.com/boolean-maybe/tiki/internal/ruki/runtime"
|
2026-01-17 16:08:53 +00:00
|
|
|
"github.com/boolean-maybe/tiki/model"
|
|
|
|
|
"github.com/boolean-maybe/tiki/plugin"
|
2026-04-09 02:46:46 +00:00
|
|
|
"github.com/boolean-maybe/tiki/ruki"
|
2026-04-05 02:05:47 +00:00
|
|
|
"github.com/boolean-maybe/tiki/service"
|
2026-01-17 16:08:53 +00:00
|
|
|
"github.com/boolean-maybe/tiki/store"
|
|
|
|
|
"github.com/boolean-maybe/tiki/store/tikistore"
|
|
|
|
|
taskpkg "github.com/boolean-maybe/tiki/task"
|
|
|
|
|
"github.com/boolean-maybe/tiki/view"
|
|
|
|
|
"github.com/boolean-maybe/tiki/view/header"
|
2026-04-19 03:09:01 +00:00
|
|
|
"github.com/boolean-maybe/tiki/view/palette"
|
2026-03-23 21:51:44 +00:00
|
|
|
"github.com/boolean-maybe/tiki/view/statusline"
|
2026-01-17 16:08:53 +00:00
|
|
|
|
|
|
|
|
"github.com/gdamore/tcell/v2"
|
|
|
|
|
"github.com/rivo/tview"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// TestApp wraps the full MVC stack for integration testing with SimulationScreen
|
|
|
|
|
type TestApp struct {
|
|
|
|
|
App *tview.Application
|
|
|
|
|
Screen tcell.SimulationScreen
|
|
|
|
|
RootLayout *view.RootLayout
|
|
|
|
|
TaskStore store.Store
|
|
|
|
|
NavController *controller.NavigationController
|
|
|
|
|
InputRouter *controller.InputRouter
|
2026-03-19 18:11:03 +00:00
|
|
|
ViewFactory *view.ViewFactory
|
2026-01-17 16:08:53 +00:00
|
|
|
TaskDir string
|
|
|
|
|
t *testing.T
|
|
|
|
|
PluginConfigs map[string]*model.PluginConfig
|
|
|
|
|
PluginControllers map[string]controller.PluginControllerInterface
|
|
|
|
|
PluginDefs []plugin.Plugin
|
2026-04-05 02:05:47 +00:00
|
|
|
MutationGate *service.TaskMutationGate
|
2026-04-09 02:46:46 +00:00
|
|
|
Schema ruki.Schema
|
2026-01-17 16:08:53 +00:00
|
|
|
taskController *controller.TaskController
|
2026-03-25 03:27:02 +00:00
|
|
|
statuslineConfig *model.StatuslineConfig
|
2026-01-17 16:08:53 +00:00
|
|
|
headerConfig *model.HeaderConfig
|
2026-04-19 02:27:14 +00:00
|
|
|
viewContext *model.ViewContext
|
2026-01-17 16:08:53 +00:00
|
|
|
layoutModel *model.LayoutModel
|
2026-04-19 03:09:01 +00:00
|
|
|
paletteConfig *model.ActionPaletteConfig
|
|
|
|
|
actionPalette *palette.ActionPalette
|
|
|
|
|
pages *tview.Pages
|
2026-01-17 16:08:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewTestApp bootstraps the full MVC stack for integration testing.
|
|
|
|
|
// Mirrors the initialization pattern from main.go.
|
|
|
|
|
func NewTestApp(t *testing.T) *TestApp {
|
2026-02-12 20:34:35 +00:00
|
|
|
// 0. Isolate config paths: use a temp XDG_CONFIG_HOME so tests don't read the real user config.
|
|
|
|
|
// This installs the default workflow.yaml into the temp config dir, mirroring the production
|
|
|
|
|
// bootstrap sequence (Phase 2.5: InstallDefaultWorkflow).
|
|
|
|
|
tmpConfigHome := t.TempDir()
|
|
|
|
|
t.Setenv("XDG_CONFIG_HOME", tmpConfigHome) // t.Setenv handles restore on cleanup
|
|
|
|
|
config.ResetPathManager()
|
|
|
|
|
if err := config.InstallDefaultWorkflow(); err != nil {
|
|
|
|
|
t.Fatalf("failed to install default workflow for test: %v", err)
|
|
|
|
|
}
|
2026-04-15 22:32:55 +00:00
|
|
|
if err := config.LoadWorkflowRegistries(); err != nil {
|
|
|
|
|
t.Fatalf("failed to load workflow registries for test: %v", err)
|
2026-03-04 16:19:12 +00:00
|
|
|
}
|
2026-02-12 20:34:35 +00:00
|
|
|
t.Cleanup(func() {
|
2026-03-04 16:19:12 +00:00
|
|
|
config.ClearStatusRegistry()
|
2026-02-12 20:34:35 +00:00
|
|
|
config.ResetPathManager()
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-09 02:46:46 +00:00
|
|
|
// 0.5. Create ruki schema (needed by plugin parser and controllers)
|
|
|
|
|
schema := rukiRuntime.NewSchema()
|
|
|
|
|
|
2026-01-17 16:08:53 +00:00
|
|
|
// 1. Create temp dir for task files (auto-cleanup via t.TempDir())
|
|
|
|
|
taskDir := t.TempDir()
|
|
|
|
|
|
|
|
|
|
// 2. Initialize Model Layer
|
|
|
|
|
taskStore, err := tikistore.NewTikiStore(taskDir)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("failed to create task store: %v", err)
|
|
|
|
|
}
|
|
|
|
|
headerConfig := model.NewHeaderConfig()
|
|
|
|
|
layoutModel := model.NewLayoutModel()
|
|
|
|
|
|
|
|
|
|
// 3. Create SimulationScreen
|
|
|
|
|
screen := tcell.NewSimulationScreen("UTF-8")
|
|
|
|
|
if err := screen.Init(); err != nil {
|
|
|
|
|
t.Fatalf("failed to init simulation screen: %v", err)
|
|
|
|
|
}
|
|
|
|
|
screen.SetSize(80, 40)
|
|
|
|
|
screen.Clear() // Clear screen after resize
|
|
|
|
|
|
|
|
|
|
// 4. Create tview.Application with SimulationScreen
|
|
|
|
|
app := tview.NewApplication()
|
|
|
|
|
app.SetScreen(screen)
|
|
|
|
|
|
|
|
|
|
// 5. Initialize Controller Layer
|
2026-04-05 02:05:47 +00:00
|
|
|
gate := service.BuildGate()
|
|
|
|
|
gate.SetStore(taskStore)
|
|
|
|
|
|
2026-03-25 03:27:02 +00:00
|
|
|
statuslineConfig := model.NewStatuslineConfig()
|
2026-01-17 16:08:53 +00:00
|
|
|
navController := controller.NewNavigationController(app)
|
2026-04-05 02:05:47 +00:00
|
|
|
taskController := controller.NewTaskController(taskStore, gate, navController, statuslineConfig)
|
2026-01-17 16:08:53 +00:00
|
|
|
// Empty plugin controllers map for tests (no plugins configured by default)
|
|
|
|
|
pluginControllers := make(map[string]controller.PluginControllerInterface)
|
|
|
|
|
inputRouter := controller.NewInputRouter(
|
|
|
|
|
navController,
|
|
|
|
|
taskController,
|
|
|
|
|
pluginControllers,
|
|
|
|
|
taskStore,
|
2026-04-05 02:05:47 +00:00
|
|
|
gate,
|
2026-03-25 03:27:02 +00:00
|
|
|
statuslineConfig,
|
2026-04-09 02:46:46 +00:00
|
|
|
schema,
|
2026-01-17 16:08:53 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// 6. Initialize View Layer
|
2026-01-25 04:21:20 +00:00
|
|
|
viewFactory := view.NewViewFactory(taskStore)
|
2026-01-17 16:08:53 +00:00
|
|
|
|
2026-03-23 21:51:44 +00:00
|
|
|
// 7. Create header widget, statusline, and RootLayout
|
2026-04-19 02:27:14 +00:00
|
|
|
viewContext := model.NewViewContext()
|
|
|
|
|
headerWidget := header.NewHeaderWidget(headerConfig, viewContext)
|
2026-03-23 21:51:44 +00:00
|
|
|
statuslineWidget := statusline.NewStatuslineWidget(statuslineConfig)
|
|
|
|
|
rootLayout := view.NewRootLayout(view.RootLayoutOpts{
|
|
|
|
|
Header: headerWidget,
|
|
|
|
|
HeaderConfig: headerConfig,
|
2026-04-19 02:27:14 +00:00
|
|
|
ViewContext: viewContext,
|
2026-03-23 21:51:44 +00:00
|
|
|
LayoutModel: layoutModel,
|
|
|
|
|
ViewFactory: viewFactory,
|
|
|
|
|
TaskStore: taskStore,
|
|
|
|
|
App: app,
|
|
|
|
|
StatuslineConfig: statuslineConfig,
|
|
|
|
|
StatuslineWidget: statuslineWidget,
|
|
|
|
|
})
|
2026-01-17 16:08:53 +00:00
|
|
|
|
|
|
|
|
rootLayout.SetOnViewActivated(func(v controller.View) {
|
2026-01-25 04:21:20 +00:00
|
|
|
if focusSettable, ok := v.(controller.FocusSettable); ok {
|
|
|
|
|
focusSettable.SetFocusSetter(func(p tview.Primitive) {
|
2026-01-17 16:08:53 +00:00
|
|
|
app.SetFocus(p)
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-01-25 04:21:20 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
currentView := rootLayout.GetContentView()
|
|
|
|
|
if currentView != nil {
|
|
|
|
|
if focusSettable, ok := currentView.(controller.FocusSettable); ok {
|
|
|
|
|
focusSettable.SetFocusSetter(func(p tview.Primitive) {
|
2026-01-17 16:08:53 +00:00
|
|
|
app.SetFocus(p)
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-01-25 04:21:20 +00:00
|
|
|
}
|
2026-01-17 16:08:53 +00:00
|
|
|
|
2026-04-19 03:09:01 +00:00
|
|
|
// 7.5 Action palette
|
|
|
|
|
paletteConfig := model.NewActionPaletteConfig()
|
|
|
|
|
inputRouter.SetHeaderConfig(headerConfig)
|
|
|
|
|
inputRouter.SetPaletteConfig(paletteConfig)
|
|
|
|
|
actionPalette := palette.NewActionPalette(viewContext, paletteConfig, inputRouter, navController)
|
|
|
|
|
actionPalette.SetChangedFunc()
|
|
|
|
|
|
|
|
|
|
// Build Pages root
|
|
|
|
|
pages := tview.NewPages()
|
|
|
|
|
pages.AddPage("base", rootLayout.GetPrimitive(), true, true)
|
|
|
|
|
paletteBox := tview.NewFlex()
|
|
|
|
|
paletteBox.AddItem(tview.NewBox(), 0, 1, false)
|
|
|
|
|
paletteBox.AddItem(actionPalette.GetPrimitive(), palette.PaletteMinWidth, 0, true)
|
|
|
|
|
pages.AddPage("palette", paletteBox, true, false)
|
|
|
|
|
|
|
|
|
|
var previousFocus tview.Primitive
|
|
|
|
|
paletteConfig.AddListener(func() {
|
|
|
|
|
if paletteConfig.IsVisible() {
|
|
|
|
|
previousFocus = app.GetFocus()
|
|
|
|
|
actionPalette.OnShow()
|
|
|
|
|
pages.ShowPage("palette")
|
|
|
|
|
app.SetFocus(actionPalette.GetFilterInput())
|
|
|
|
|
} else {
|
|
|
|
|
pages.HidePage("palette")
|
|
|
|
|
if previousFocus != nil {
|
|
|
|
|
app.SetFocus(previousFocus)
|
|
|
|
|
} else if cv := rootLayout.GetContentView(); cv != nil {
|
|
|
|
|
app.SetFocus(cv.GetPrimitive())
|
|
|
|
|
}
|
|
|
|
|
previousFocus = nil
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-01-17 16:08:53 +00:00
|
|
|
// 8. Wire up callbacks
|
|
|
|
|
navController.SetOnViewChanged(func(viewID model.ViewID, params map[string]interface{}) {
|
|
|
|
|
layoutModel.SetContent(viewID, params)
|
|
|
|
|
})
|
|
|
|
|
navController.SetActiveViewGetter(rootLayout.GetContentView)
|
|
|
|
|
|
2026-04-19 03:09:01 +00:00
|
|
|
// 9. Set up global input capture (matches production)
|
2026-01-17 16:08:53 +00:00
|
|
|
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
2026-04-19 03:09:01 +00:00
|
|
|
if paletteConfig.IsVisible() {
|
|
|
|
|
return event
|
2026-01-17 16:08:53 +00:00
|
|
|
}
|
2026-04-19 03:09:01 +00:00
|
|
|
statuslineConfig.DismissAutoHide()
|
|
|
|
|
if inputRouter.HandleInput(event, navController.CurrentView()) {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return event
|
2026-01-17 16:08:53 +00:00
|
|
|
})
|
|
|
|
|
|
2026-04-19 03:09:01 +00:00
|
|
|
// 10. Set root (Pages)
|
|
|
|
|
app.SetRoot(pages, true).EnableMouse(false)
|
2026-01-17 16:08:53 +00:00
|
|
|
|
|
|
|
|
// Note: Do NOT call app.Run() - we use app.Draw() + screen.Show() for synchronous testing
|
|
|
|
|
|
2026-01-25 04:21:20 +00:00
|
|
|
ta := &TestApp{
|
2026-03-25 03:27:02 +00:00
|
|
|
App: app,
|
|
|
|
|
Screen: screen,
|
|
|
|
|
RootLayout: rootLayout,
|
|
|
|
|
TaskStore: taskStore,
|
2026-04-05 02:05:47 +00:00
|
|
|
MutationGate: gate,
|
2026-04-09 02:46:46 +00:00
|
|
|
Schema: schema,
|
2026-03-25 03:27:02 +00:00
|
|
|
NavController: navController,
|
|
|
|
|
InputRouter: inputRouter,
|
|
|
|
|
TaskDir: taskDir,
|
|
|
|
|
t: t,
|
|
|
|
|
taskController: taskController,
|
|
|
|
|
statuslineConfig: statuslineConfig,
|
|
|
|
|
headerConfig: headerConfig,
|
2026-04-19 02:27:14 +00:00
|
|
|
viewContext: viewContext,
|
2026-03-25 03:27:02 +00:00
|
|
|
layoutModel: layoutModel,
|
2026-04-19 03:09:01 +00:00
|
|
|
paletteConfig: paletteConfig,
|
|
|
|
|
actionPalette: actionPalette,
|
|
|
|
|
pages: pages,
|
2026-01-25 04:21:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 11. Auto-load plugins since all views are now plugins
|
|
|
|
|
if err := ta.LoadPlugins(); err != nil {
|
|
|
|
|
t.Fatalf("failed to load plugins: %v", err)
|
2026-01-17 16:08:53 +00:00
|
|
|
}
|
2026-01-25 04:21:20 +00:00
|
|
|
|
|
|
|
|
return ta
|
2026-01-17 16:08:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Draw forces a synchronous draw without running the app event loop
|
|
|
|
|
func (ta *TestApp) Draw() {
|
|
|
|
|
_, width, height := ta.Screen.GetContents()
|
2026-04-19 03:09:01 +00:00
|
|
|
ta.pages.SetRect(0, 0, width, height)
|
|
|
|
|
ta.pages.Draw(ta.Screen)
|
2026-01-17 16:08:53 +00:00
|
|
|
ta.Screen.Show()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SendKey simulates a key press by directly calling the input capture handler.
|
|
|
|
|
// Input flows through app's InputCapture → InputRouter.HandleInput.
|
|
|
|
|
// If InputCapture doesn't consume the event, it's forwarded to the focused primitive.
|
|
|
|
|
func (ta *TestApp) SendKey(key tcell.Key, ch rune, mod tcell.ModMask) {
|
|
|
|
|
event := tcell.NewEventKey(key, ch, mod)
|
|
|
|
|
// Directly call the input capture handler (synchronous, no event loop needed)
|
|
|
|
|
consumed := false
|
|
|
|
|
if capture := ta.App.GetInputCapture(); capture != nil {
|
|
|
|
|
returnedEvent := capture(event)
|
|
|
|
|
consumed = (returnedEvent == nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If InputCapture didn't consume the event, send it to the focused primitive
|
|
|
|
|
if !consumed {
|
|
|
|
|
focused := ta.App.GetFocus()
|
|
|
|
|
if focused != nil {
|
|
|
|
|
handler := focused.InputHandler()
|
|
|
|
|
if handler != nil {
|
|
|
|
|
handler(event, func(p tview.Primitive) { ta.App.SetFocus(p) })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Redraw after input
|
|
|
|
|
ta.Draw()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetTextAt extracts text from a screen region starting at (x, y) with given width
|
|
|
|
|
func (ta *TestApp) GetTextAt(x, y, width int) string {
|
|
|
|
|
contents, screenWidth, _ := ta.Screen.GetContents()
|
|
|
|
|
var result strings.Builder
|
|
|
|
|
|
|
|
|
|
for i := 0; i < width; i++ {
|
|
|
|
|
cellIdx := y*screenWidth + (x + i)
|
|
|
|
|
if cellIdx >= len(contents) {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
cell := contents[cellIdx]
|
|
|
|
|
if len(cell.Runes) > 0 {
|
|
|
|
|
result.WriteRune(cell.Runes[0])
|
|
|
|
|
} else {
|
|
|
|
|
result.WriteRune(' ')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return strings.TrimSpace(result.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// FindText searches for a text string anywhere on the screen.
|
|
|
|
|
// Returns (found, x, y) where x, y are the coordinates of the first match.
|
|
|
|
|
func (ta *TestApp) FindText(needle string) (bool, int, int) {
|
|
|
|
|
_, width, height := ta.Screen.GetContents()
|
|
|
|
|
|
|
|
|
|
// Search row by row
|
|
|
|
|
for y := 0; y < height; y++ {
|
|
|
|
|
// Extract full row text
|
|
|
|
|
rowText := ta.GetTextAt(0, y, width)
|
|
|
|
|
if strings.Contains(rowText, needle) {
|
|
|
|
|
// Find x position within row
|
|
|
|
|
x := strings.Index(rowText, needle)
|
|
|
|
|
return true, x, y
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false, 0, 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DumpScreen prints the current screen content for debugging
|
|
|
|
|
func (ta *TestApp) DumpScreen() {
|
|
|
|
|
_, width, height := ta.Screen.GetContents()
|
|
|
|
|
ta.t.Logf("Screen size: %dx%d", width, height)
|
|
|
|
|
for y := 0; y < height; y++ {
|
|
|
|
|
line := ta.GetTextAt(0, y, width)
|
|
|
|
|
if line != "" {
|
|
|
|
|
ta.t.Logf("Row %2d: %s", y, line)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SendKeyToFocused sends a key event directly to the focused primitive's InputHandler.
|
|
|
|
|
// Use this for text input into InputField, TextArea, etc.
|
|
|
|
|
func (ta *TestApp) SendKeyToFocused(key tcell.Key, ch rune, mod tcell.ModMask) {
|
|
|
|
|
event := tcell.NewEventKey(key, ch, mod)
|
|
|
|
|
focused := ta.App.GetFocus()
|
|
|
|
|
if focused != nil {
|
|
|
|
|
handler := focused.InputHandler()
|
|
|
|
|
if handler != nil {
|
|
|
|
|
handler(event, func(p tview.Primitive) { ta.App.SetFocus(p) })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
ta.Draw()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SendText types a string of characters into the focused primitive
|
|
|
|
|
func (ta *TestApp) SendText(text string) {
|
|
|
|
|
for _, ch := range text {
|
|
|
|
|
ta.SendKey(tcell.KeyRune, ch, tcell.ModNone)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// EditingTask returns the current in-memory editing copy (if any).
|
|
|
|
|
func (ta *TestApp) EditingTask() *taskpkg.Task {
|
|
|
|
|
return ta.taskController.GetEditingTask()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DraftTask returns the current draft task (if any).
|
|
|
|
|
func (ta *TestApp) DraftTask() *taskpkg.Task {
|
|
|
|
|
return ta.taskController.GetDraftTask()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cleanup tears down the test app and releases resources
|
|
|
|
|
func (ta *TestApp) Cleanup() {
|
2026-04-19 03:09:01 +00:00
|
|
|
ta.actionPalette.Cleanup()
|
2026-01-17 16:08:53 +00:00
|
|
|
ta.RootLayout.Cleanup()
|
|
|
|
|
ta.Screen.Fini()
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 20:34:35 +00:00
|
|
|
// LoadPlugins loads plugins from workflow.yaml files and wires them into the test app.
|
2026-01-17 16:08:53 +00:00
|
|
|
// This enables testing of plugin-related functionality.
|
|
|
|
|
func (ta *TestApp) LoadPlugins() error {
|
|
|
|
|
// Load embedded plugins
|
2026-04-09 02:46:46 +00:00
|
|
|
plugins, err := plugin.LoadPlugins(ta.Schema)
|
2026-01-17 16:08:53 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create configs and controllers for each plugin
|
|
|
|
|
pluginConfigs := make(map[string]*model.PluginConfig)
|
|
|
|
|
pluginControllers := make(map[string]controller.PluginControllerInterface)
|
|
|
|
|
|
|
|
|
|
for _, p := range plugins {
|
|
|
|
|
pc := model.NewPluginConfig(p.GetName())
|
|
|
|
|
pc.SetConfigIndex(p.GetConfigIndex())
|
|
|
|
|
pluginConfigs[p.GetName()] = pc
|
|
|
|
|
|
|
|
|
|
// Create appropriate controller based on plugin type
|
|
|
|
|
if tp, ok := p.(*plugin.TikiPlugin); ok {
|
2026-02-10 21:18:05 +00:00
|
|
|
columns := make([]int, len(tp.Lanes))
|
2026-03-24 03:58:26 +00:00
|
|
|
widths := make([]int, len(tp.Lanes))
|
2026-02-10 21:18:05 +00:00
|
|
|
for i, lane := range tp.Lanes {
|
|
|
|
|
columns[i] = lane.Columns
|
2026-03-24 03:58:26 +00:00
|
|
|
widths[i] = lane.Width
|
2026-01-21 21:16:54 +00:00
|
|
|
}
|
2026-03-24 03:58:26 +00:00
|
|
|
pc.SetLaneLayout(columns, widths)
|
2026-01-17 16:08:53 +00:00
|
|
|
pluginControllers[p.GetName()] = controller.NewPluginController(
|
2026-04-09 02:46:46 +00:00
|
|
|
ta.TaskStore, ta.MutationGate, pc, tp, ta.NavController, ta.statuslineConfig, ta.Schema,
|
2026-01-17 16:08:53 +00:00
|
|
|
)
|
|
|
|
|
} else if dp, ok := p.(*plugin.DokiPlugin); ok {
|
|
|
|
|
pluginControllers[p.GetName()] = controller.NewDokiController(
|
2026-03-25 03:27:02 +00:00
|
|
|
dp, ta.NavController, ta.statuslineConfig,
|
2026-01-17 16:08:53 +00:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update TestApp fields
|
|
|
|
|
ta.PluginConfigs = pluginConfigs
|
|
|
|
|
ta.PluginControllers = pluginControllers
|
|
|
|
|
ta.PluginDefs = plugins
|
|
|
|
|
|
|
|
|
|
// Initialize plugin action registry (must happen after plugins are loaded)
|
|
|
|
|
pluginInfos := make([]controller.PluginInfo, 0, len(plugins))
|
|
|
|
|
for _, p := range plugins {
|
|
|
|
|
pk, pr, pm := p.GetActivationKey()
|
|
|
|
|
pluginInfos = append(pluginInfos, controller.PluginInfo{
|
|
|
|
|
Name: p.GetName(),
|
|
|
|
|
Key: pk,
|
|
|
|
|
Rune: pr,
|
|
|
|
|
Modifier: pm,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
controller.InitPluginActions(pluginInfos)
|
|
|
|
|
|
|
|
|
|
// Recreate InputRouter with plugin controllers
|
|
|
|
|
ta.InputRouter = controller.NewInputRouter(
|
|
|
|
|
ta.NavController,
|
|
|
|
|
ta.taskController,
|
|
|
|
|
pluginControllers,
|
|
|
|
|
ta.TaskStore,
|
2026-04-05 02:05:47 +00:00
|
|
|
ta.MutationGate,
|
2026-03-25 03:27:02 +00:00
|
|
|
ta.statuslineConfig,
|
2026-04-09 02:46:46 +00:00
|
|
|
ta.Schema,
|
2026-01-17 16:08:53 +00:00
|
|
|
)
|
2026-04-19 03:09:01 +00:00
|
|
|
ta.InputRouter.SetHeaderConfig(ta.headerConfig)
|
|
|
|
|
ta.InputRouter.SetPaletteConfig(ta.paletteConfig)
|
2026-01-17 16:08:53 +00:00
|
|
|
|
2026-04-19 03:09:01 +00:00
|
|
|
// Update global input capture (matches production pipeline)
|
2026-01-17 16:08:53 +00:00
|
|
|
ta.App.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
2026-04-19 03:09:01 +00:00
|
|
|
if ta.paletteConfig.IsVisible() {
|
|
|
|
|
return event
|
2026-01-17 16:08:53 +00:00
|
|
|
}
|
2026-04-19 03:09:01 +00:00
|
|
|
ta.statuslineConfig.DismissAutoHide()
|
|
|
|
|
if ta.InputRouter.HandleInput(event, ta.NavController.CurrentView()) {
|
|
|
|
|
return nil
|
2026-01-17 16:08:53 +00:00
|
|
|
}
|
2026-04-19 03:09:01 +00:00
|
|
|
return event
|
2026-01-17 16:08:53 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Update ViewFactory with plugins
|
|
|
|
|
// Convert plugin slice to map for ViewFactory
|
|
|
|
|
pluginDefs := make(map[string]plugin.Plugin)
|
|
|
|
|
for _, p := range plugins {
|
|
|
|
|
pluginDefs[p.GetName()] = p
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-25 04:21:20 +00:00
|
|
|
viewFactory := view.NewViewFactory(ta.TaskStore)
|
2026-01-17 16:08:53 +00:00
|
|
|
viewFactory.SetPlugins(pluginConfigs, pluginDefs, pluginControllers)
|
2026-03-19 18:11:03 +00:00
|
|
|
ta.ViewFactory = viewFactory
|
|
|
|
|
|
|
|
|
|
// Wire dynamic plugin registration so openDepsEditor can register deps views at runtime.
|
|
|
|
|
// Mirrors bootstrap/init.go:133-135.
|
|
|
|
|
ta.InputRouter.SetPluginRegistrar(func(name string, cfg *model.PluginConfig, def plugin.Plugin, ctrl controller.PluginControllerInterface) {
|
|
|
|
|
viewFactory.RegisterPlugin(name, cfg, def, ctrl)
|
|
|
|
|
})
|
2026-01-17 16:08:53 +00:00
|
|
|
|
|
|
|
|
// Recreate RootLayout with new view factory
|
2026-04-19 02:27:14 +00:00
|
|
|
headerWidget := header.NewHeaderWidget(ta.headerConfig, ta.viewContext)
|
2026-01-17 16:08:53 +00:00
|
|
|
ta.RootLayout.Cleanup()
|
2026-03-23 21:51:44 +00:00
|
|
|
slConfig := model.NewStatuslineConfig()
|
|
|
|
|
slWidget := statusline.NewStatuslineWidget(slConfig)
|
|
|
|
|
ta.RootLayout = view.NewRootLayout(view.RootLayoutOpts{
|
|
|
|
|
Header: headerWidget,
|
|
|
|
|
HeaderConfig: ta.headerConfig,
|
2026-04-19 02:27:14 +00:00
|
|
|
ViewContext: ta.viewContext,
|
2026-03-23 21:51:44 +00:00
|
|
|
LayoutModel: ta.layoutModel,
|
|
|
|
|
ViewFactory: viewFactory,
|
|
|
|
|
TaskStore: ta.TaskStore,
|
|
|
|
|
App: ta.App,
|
|
|
|
|
StatuslineConfig: slConfig,
|
|
|
|
|
StatuslineWidget: slWidget,
|
|
|
|
|
})
|
2026-01-17 16:08:53 +00:00
|
|
|
|
|
|
|
|
// Re-wire callbacks
|
|
|
|
|
ta.NavController.SetActiveViewGetter(ta.RootLayout.GetContentView)
|
|
|
|
|
|
2026-01-25 04:21:20 +00:00
|
|
|
// IMPORTANT: Re-wire OnViewActivated callback for focus management
|
|
|
|
|
ta.RootLayout.SetOnViewActivated(func(v controller.View) {
|
|
|
|
|
if focusSettable, ok := v.(controller.FocusSettable); ok {
|
|
|
|
|
focusSettable.SetFocusSetter(func(p tview.Primitive) {
|
|
|
|
|
ta.App.SetFocus(p)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Retroactively wire focus setter for current view
|
|
|
|
|
if currentView := ta.RootLayout.GetContentView(); currentView != nil {
|
|
|
|
|
if focusSettable, ok := currentView.(controller.FocusSettable); ok {
|
|
|
|
|
focusSettable.SetFocusSetter(func(p tview.Primitive) {
|
|
|
|
|
ta.App.SetFocus(p)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 03:09:01 +00:00
|
|
|
// Update palette with new view context
|
|
|
|
|
ta.actionPalette.Cleanup()
|
|
|
|
|
ta.actionPalette = palette.NewActionPalette(ta.viewContext, ta.paletteConfig, ta.InputRouter, ta.NavController)
|
|
|
|
|
ta.actionPalette.SetChangedFunc()
|
|
|
|
|
|
|
|
|
|
// Rebuild Pages
|
|
|
|
|
ta.pages.RemovePage("palette")
|
|
|
|
|
ta.pages.RemovePage("base")
|
|
|
|
|
ta.pages.AddPage("base", ta.RootLayout.GetPrimitive(), true, true)
|
|
|
|
|
paletteBox := tview.NewFlex()
|
|
|
|
|
paletteBox.AddItem(tview.NewBox(), 0, 1, false)
|
|
|
|
|
paletteBox.AddItem(ta.actionPalette.GetPrimitive(), palette.PaletteMinWidth, 0, true)
|
|
|
|
|
ta.pages.AddPage("palette", paletteBox, true, false)
|
|
|
|
|
|
|
|
|
|
ta.App.SetRoot(ta.pages, true)
|
2026-01-17 16:08:53 +00:00
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 03:09:01 +00:00
|
|
|
// GetHeaderConfig returns the header config for testing visibility assertions.
|
|
|
|
|
func (ta *TestApp) GetHeaderConfig() *model.HeaderConfig {
|
|
|
|
|
return ta.headerConfig
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetPaletteConfig returns the palette config for testing visibility assertions.
|
|
|
|
|
func (ta *TestApp) GetPaletteConfig() *model.ActionPaletteConfig {
|
|
|
|
|
return ta.paletteConfig
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-17 16:08:53 +00:00
|
|
|
// GetPluginConfig retrieves the PluginConfig for a given plugin name.
|
|
|
|
|
// Returns nil if the plugin is not loaded.
|
|
|
|
|
func (ta *TestApp) GetPluginConfig(pluginName string) *model.PluginConfig {
|
|
|
|
|
return ta.PluginConfigs[pluginName]
|
|
|
|
|
}
|
2026-03-19 18:27:45 +00:00
|
|
|
|
|
|
|
|
// NavigateToTask presses Down on the current board view until the task with the given ID
|
|
|
|
|
// is the selected item. It opens the task detail (Enter) and returns true if found within
|
|
|
|
|
// maxSteps attempts; returns false if the task was not found.
|
|
|
|
|
func (ta *TestApp) NavigateToTask(taskID string, maxSteps int) bool {
|
|
|
|
|
for i := 0; i < maxSteps; i++ {
|
|
|
|
|
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
|
|
|
|
ta.Draw()
|
|
|
|
|
if found, _, _ := ta.FindText(taskID); found {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
// go back and move to next item
|
|
|
|
|
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
|
|
|
|
ta.Draw()
|
|
|
|
|
ta.SendKey(tcell.KeyDown, 0, tcell.ModNone)
|
|
|
|
|
ta.Draw()
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|