tiki/testutil/integration_helpers.go

551 lines
17 KiB
Go
Raw Normal View History

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
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) {
if focusSettable, ok := v.(controller.FocusSettable); ok {
focusSettable.SetFocusSetter(func(p tview.Primitive) {
2026-01-17 16:08:53 +00:00
app.SetFocus(p)
})
}
})
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-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
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,
}
// 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
}
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
}
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)
// 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
}