summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-18 07:45:37 +0300
committerPaul Buetow <paul@buetow.org>2026-06-18 07:45:37 +0300
commit4ffb22e7f69f1c9c79b095d4e60bad3d97aac55b (patch)
tree2dc708cbb95975d34084eb5afd376871caa13e27
parentece7dcfd232b780f5650326c8ac2379ca70387d4 (diff)
ik0 replace test seams with dependency injection
-rw-r--r--cmd/ask/main.go16
-rw-r--r--cmd/ask/main_test.go28
-rw-r--r--cmd/hexai-tmux-action/main.go32
-rw-r--r--cmd/hexai-tmux-action/main_test.go52
-rw-r--r--cmd/hexai-tmux-edit/main.go13
-rw-r--r--cmd/hexai-tmux-edit/main_test.go39
-rw-r--r--internal/askcli/command_edit.go10
-rw-r--r--internal/askcli/command_edit_test.go31
-rw-r--r--internal/askcli/command_watch.go7
-rw-r--r--internal/askcli/command_watch_test.go33
-rw-r--r--internal/askcli/dispatch.go17
-rw-r--r--internal/editor/editor.go36
-rw-r--r--internal/editor/editor_test.go56
-rw-r--r--internal/hexaiaction/cmdentry.go54
-rw-r--r--internal/hexaiaction/cmdentry_runcommand_test.go18
-rw-r--r--internal/hexaiaction/cmdentry_test.go36
-rw-r--r--internal/hexaiaction/custom_action_test.go9
-rw-r--r--internal/hexaiaction/run.go28
-rw-r--r--internal/hexaiaction/tui.go12
-rw-r--r--internal/hexaiaction/tui_config_test.go9
-rw-r--r--internal/hexaiaction/tui_custom.go22
-rw-r--r--internal/hexaiaction/tui_custom_test.go9
-rw-r--r--internal/hexaicli/cache.go57
-rw-r--r--internal/hexaicli/cache_test.go25
-rw-r--r--internal/hexaicli/editor_integration_test.go33
-rw-r--r--internal/hexaicli/run.go8
-rw-r--r--internal/hexaicli/run_editor_behavior_test.go17
-rw-r--r--internal/hexaicli/runner.go23
-rw-r--r--internal/hexaicli/runner_test.go15
-rw-r--r--internal/stats/stats.go34
-rw-r--r--internal/stats/stats_test.go12
-rw-r--r--internal/tmux/status.go9
-rw-r--r--internal/tmux/status_coverage_test.go8
-rw-r--r--internal/tmux/tmux.go42
-rw-r--r--internal/tmux/tmux_test.go28
-rw-r--r--internal/tmuxedit/agent.go21
-rw-r--r--internal/tmuxedit/agent_test.go16
-rw-r--r--internal/tmuxedit/agentutil.go16
-rw-r--r--internal/tmuxedit/agentutil_test.go24
-rw-r--r--internal/tmuxedit/capture.go16
-rw-r--r--internal/tmuxedit/capture_test.go24
-rw-r--r--internal/tmuxedit/cursor_agent.go4
-rw-r--r--internal/tmuxedit/cursor_agent_test.go20
-rw-r--r--internal/tmuxedit/pane.go28
-rw-r--r--internal/tmuxedit/pane_test.go40
-rw-r--r--internal/tmuxedit/run.go45
-rw-r--r--internal/tmuxedit/run_test.go195
-rw-r--r--internal/tmuxedit/send.go39
-rw-r--r--internal/tmuxedit/send_test.go40
49 files changed, 729 insertions, 677 deletions
diff --git a/cmd/ask/main.go b/cmd/ask/main.go
index fbd5bb0..689cce0 100644
--- a/cmd/ask/main.go
+++ b/cmd/ask/main.go
@@ -15,19 +15,21 @@ type dispatcher interface {
Dispatch(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error)
}
-// dispatcherFactory is a test seam: override to inject a fake dispatcher so
-// runMain can be exercised without a real `task` binary on PATH.
-var dispatcherFactory = func() dispatcher {
- return askcli.NewDispatcher(nil)
+type app struct {
+ newDispatcher func() dispatcher
}
-func main() { os.Exit(runMain(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) }
+func newApp() *app {
+ return &app{newDispatcher: func() dispatcher { return askcli.NewDispatcher(nil) }}
+}
+
+func main() { os.Exit(newApp().runMain(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) }
// runMain dispatches the command and returns the process exit code; errors
// are printed to stderr. The dispatcher's exit code is returned regardless
// of err so callers see Taskwarrior's own exit code on failure paths.
-func runMain(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
- code, err := dispatcherFactory().Dispatch(context.Background(), args, stdin, stdout, stderr)
+func (a *app) runMain(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
+ code, err := a.newDispatcher().Dispatch(context.Background(), args, stdin, stdout, stderr)
if err != nil {
fmt.Fprintln(stderr, err)
}
diff --git a/cmd/ask/main_test.go b/cmd/ask/main_test.go
index 643dde4..0f6a0c7 100644
--- a/cmd/ask/main_test.go
+++ b/cmd/ask/main_test.go
@@ -68,14 +68,11 @@ func (f *fakeDispatcher) Dispatch(_ context.Context, args []string, _ io.Reader,
// Driving runMain through a fake dispatcher proves the wiring (args
// forwarded, exit code returned) without touching the real ask CLI.
func TestRunMain_DelegatesAndReturnsCode(t *testing.T) {
- old := dispatcherFactory
- t.Cleanup(func() { dispatcherFactory = old })
-
fake := &fakeDispatcher{code: 0}
- dispatcherFactory = func() dispatcher { return fake }
+ a := &app{newDispatcher: func() dispatcher { return fake }}
var stdout, stderr bytes.Buffer
- got := runMain([]string{"list", "limit:1"}, nil, &stdout, &stderr)
+ got := a.runMain([]string{"list", "limit:1"}, nil, &stdout, &stderr)
if got != 0 {
t.Fatalf("runMain code = %d, want 0", got)
}
@@ -87,30 +84,27 @@ func TestRunMain_DelegatesAndReturnsCode(t *testing.T) {
}
}
-// The default dispatcherFactory must return a working real dispatcher (this
-// is the path main() uses in production); fakes used elsewhere don't cover
-// it, so verify it explicitly.
-func TestDispatcherFactory_DefaultReturnsRealDispatcher(t *testing.T) {
- d := dispatcherFactory()
+// The default app dispatcher factory must return a working real dispatcher
+// (this is the path main() uses in production); fakes used elsewhere don't
+// cover it, so verify it explicitly.
+func TestNewApp_DefaultReturnsRealDispatcher(t *testing.T) {
+ d := newApp().newDispatcher()
if d == nil {
- t.Fatal("default dispatcherFactory returned nil")
+ t.Fatal("default dispatcher factory returned nil")
}
if _, ok := d.(*askcli.Dispatcher); !ok {
- t.Fatalf("default dispatcherFactory returned %T, want *askcli.Dispatcher", d)
+ t.Fatalf("default dispatcher factory returned %T, want *askcli.Dispatcher", d)
}
}
// On a dispatcher error, runMain must print the error to stderr AND surface
// the dispatcher's exit code so the shell sees Taskwarrior's own status.
func TestRunMain_PrintsErrorAndPropagatesExitCode(t *testing.T) {
- old := dispatcherFactory
- t.Cleanup(func() { dispatcherFactory = old })
-
fake := &fakeDispatcher{code: 7, err: errors.New("dispatch boom")}
- dispatcherFactory = func() dispatcher { return fake }
+ a := &app{newDispatcher: func() dispatcher { return fake }}
var stdout, stderr bytes.Buffer
- got := runMain(nil, nil, &stdout, &stderr)
+ got := a.runMain(nil, nil, &stdout, &stderr)
if got != 7 {
t.Fatalf("runMain code = %d, want 7", got)
}
diff --git a/cmd/hexai-tmux-action/main.go b/cmd/hexai-tmux-action/main.go
index e2f50eb..8830aa8 100644
--- a/cmd/hexai-tmux-action/main.go
+++ b/cmd/hexai-tmux-action/main.go
@@ -12,18 +12,20 @@ import (
"codeberg.org/snonux/hexai/internal/hexaiaction"
)
-// runCommand is the seam for testing: override in tests to avoid launching
-// the real tmux action.
-var runCommand = hexaiaction.RunCommand
+// actionRunner is the dependency that performs the tmux action. Production
+// code uses hexaiaction.RunCommand; tests inject a stub to avoid launching the
+// real tmux popup.
+type actionRunner func(context.Context, hexaiaction.Options, io.Reader, io.Writer, io.Writer) error
-func main() { os.Exit(runMain(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) }
+func main() { os.Exit(newApp().runMain(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) }
// runMain parses command-line flags from args, builds actionOptions, and
// delegates to run. It returns the process exit code: 2 for flag-parse
// errors (matching stdlib `flag.ExitOnError`), 1 for runtime failures, 0 on
// success. Splitting the body out of main keeps it testable without
-// touching package-level flag state.
-func runMain(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
+// touching package-level flag state. It is a method on app so tests can inject
+// a stub runCommand.
+func (a *app) runMain(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("hexai-tmux-action", flag.ContinueOnError)
fs.SetOutput(stderr)
infile := fs.String("infile", "", "Read input from this file instead of stdin")
@@ -43,7 +45,7 @@ func runMain(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
uiChild: *uiChild, configPath: *configPath,
tmuxTarget: *tmuxTarget, tmuxPopupWidth: *tmuxPopupWidth, tmuxPopupHeight: *tmuxPopupHeight,
}
- if err := run(opts, stdin, stdout, stderr); err != nil {
+ if err := a.run(opts, stdin, stdout, stderr); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
@@ -61,8 +63,18 @@ type actionOptions struct {
tmuxPopupHeight string
}
-// run builds the hexaiaction.Options and context, then delegates to runCommand.
-func run(opts actionOptions, stdin io.Reader, stdout, stderr io.Writer) error {
+// app wires the injected dependencies for the command. runCommand defaults to
+// hexaiaction.RunCommand in production and is replaced by tests.
+type app struct {
+ runCommand actionRunner
+}
+
+// newApp returns an app with the production action runner installed.
+func newApp() *app { return &app{runCommand: hexaiaction.RunCommand} }
+
+// run builds the hexaiaction.Options and context, then delegates to the
+// injected runCommand dependency.
+func (a *app) run(opts actionOptions, stdin io.Reader, stdout, stderr io.Writer) error {
haOpts := hexaiaction.Options{
Infile: opts.infile, Outfile: opts.outfile,
UIChild: opts.uiChild, TmuxTarget: opts.tmuxTarget,
@@ -72,5 +84,5 @@ func run(opts actionOptions, stdin io.Reader, stdout, stderr io.Writer) error {
if path := strings.TrimSpace(opts.configPath); path != "" {
ctx = hexaiaction.WithConfigPath(ctx, path)
}
- return runCommand(ctx, haOpts, stdin, stdout, stderr)
+ return a.runCommand(ctx, haOpts, stdin, stdout, stderr)
}
diff --git a/cmd/hexai-tmux-action/main_test.go b/cmd/hexai-tmux-action/main_test.go
index e1c02e1..98f0c7c 100644
--- a/cmd/hexai-tmux-action/main_test.go
+++ b/cmd/hexai-tmux-action/main_test.go
@@ -12,20 +12,17 @@ import (
)
func TestRun_DelegatesToRunCommand(t *testing.T) {
- old := runCommand
- t.Cleanup(func() { runCommand = old })
-
var gotOpts hexaiaction.Options
- runCommand = func(_ context.Context, opts hexaiaction.Options, _ io.Reader, _, _ io.Writer) error {
+ a := &app{runCommand: func(_ context.Context, opts hexaiaction.Options, _ io.Reader, _, _ io.Writer) error {
gotOpts = opts
return nil
- }
+ }}
opts := actionOptions{
infile: "in.txt", outfile: "out.txt",
tmuxPopupWidth: "90%", tmuxPopupHeight: "70%",
}
- if err := run(opts, nil, nil, nil); err != nil {
+ if err := a.run(opts, nil, nil, nil); err != nil {
t.Fatalf("run: %v", err)
}
if gotOpts.Infile != "in.txt" || gotOpts.Outfile != "out.txt" {
@@ -37,29 +34,23 @@ func TestRun_DelegatesToRunCommand(t *testing.T) {
}
func TestRun_WithConfigPath(t *testing.T) {
- old := runCommand
- t.Cleanup(func() { runCommand = old })
-
- runCommand = func(_ context.Context, _ hexaiaction.Options, _ io.Reader, _, _ io.Writer) error {
+ a := &app{runCommand: func(_ context.Context, _ hexaiaction.Options, _ io.Reader, _, _ io.Writer) error {
return nil
- }
+ }}
opts := actionOptions{configPath: " /tmp/test.toml "}
- if err := run(opts, nil, nil, nil); err != nil {
+ if err := a.run(opts, nil, nil, nil); err != nil {
t.Fatalf("run: %v", err)
}
}
func TestRun_Error(t *testing.T) {
- old := runCommand
- t.Cleanup(func() { runCommand = old })
-
wantErr := errors.New("action failed")
- runCommand = func(_ context.Context, _ hexaiaction.Options, _ io.Reader, _, _ io.Writer) error {
+ a := &app{runCommand: func(_ context.Context, _ hexaiaction.Options, _ io.Reader, _, _ io.Writer) error {
return wantErr
- }
+ }}
- if err := run(actionOptions{}, nil, nil, nil); !errors.Is(err, wantErr) {
+ if err := a.run(actionOptions{}, nil, nil, nil); !errors.Is(err, wantErr) {
t.Fatalf("expected error, got: %v", err)
}
}
@@ -68,14 +59,11 @@ func TestRun_Error(t *testing.T) {
// the stub returns 0. The captured Options confirm the field-by-field
// mapping that main relies on.
func TestRunMain_FlagsForwardedToHexaiaction(t *testing.T) {
- old := runCommand
- t.Cleanup(func() { runCommand = old })
-
var got hexaiaction.Options
- runCommand = func(_ context.Context, opts hexaiaction.Options, _ io.Reader, _, _ io.Writer) error {
+ a := &app{runCommand: func(_ context.Context, opts hexaiaction.Options, _ io.Reader, _, _ io.Writer) error {
got = opts
return nil
- }
+ }}
args := []string{
"-infile", "in.txt",
@@ -86,7 +74,7 @@ func TestRunMain_FlagsForwardedToHexaiaction(t *testing.T) {
"-ui-child",
}
var stderr bytes.Buffer
- code := runMain(args, nil, &bytes.Buffer{}, &stderr)
+ code := a.runMain(args, nil, &bytes.Buffer{}, &stderr)
if code != 0 {
t.Fatalf("runMain code = %d, want 0; stderr=%q", code, stderr.String())
}
@@ -104,14 +92,12 @@ func TestRunMain_FlagsForwardedToHexaiaction(t *testing.T) {
// On runCommand failure, runMain returns 1 (the production exit code) and
// writes the error message to stderr so users see what went wrong.
func TestRunMain_RuntimeErrorReturnsOne(t *testing.T) {
- old := runCommand
- t.Cleanup(func() { runCommand = old })
- runCommand = func(context.Context, hexaiaction.Options, io.Reader, io.Writer, io.Writer) error {
+ a := &app{runCommand: func(context.Context, hexaiaction.Options, io.Reader, io.Writer, io.Writer) error {
return errors.New("action exploded")
- }
+ }}
var stderr bytes.Buffer
- code := runMain(nil, nil, &bytes.Buffer{}, &stderr)
+ code := a.runMain(nil, nil, &bytes.Buffer{}, &stderr)
if code != 1 {
t.Fatalf("runMain code = %d, want 1", code)
}
@@ -122,15 +108,13 @@ func TestRunMain_RuntimeErrorReturnsOne(t *testing.T) {
// Bad flag must yield exit 2 without ever invoking runCommand.
func TestRunMain_BadFlagReturnsTwo(t *testing.T) {
- old := runCommand
- t.Cleanup(func() { runCommand = old })
called := false
- runCommand = func(context.Context, hexaiaction.Options, io.Reader, io.Writer, io.Writer) error {
+ a := &app{runCommand: func(context.Context, hexaiaction.Options, io.Reader, io.Writer, io.Writer) error {
called = true
return nil
- }
+ }}
var stderr bytes.Buffer
- code := runMain([]string{"--bogus"}, nil, &bytes.Buffer{}, &stderr)
+ code := a.runMain([]string{"--bogus"}, nil, &bytes.Buffer{}, &stderr)
if code != 2 {
t.Fatalf("runMain code = %d, want 2", code)
}
diff --git a/cmd/hexai-tmux-edit/main.go b/cmd/hexai-tmux-edit/main.go
index 6177008..d61f68a 100644
--- a/cmd/hexai-tmux-edit/main.go
+++ b/cmd/hexai-tmux-edit/main.go
@@ -22,15 +22,18 @@ import (
"codeberg.org/snonux/hexai/internal/tmuxedit"
)
-// runTmuxEdit is the seam for testing: override in tests to avoid real tmux.
-var runTmuxEdit = tmuxedit.Run
+type app struct {
+ runTmuxEdit func(tmuxedit.Options) error
+}
+
+func newApp() *app { return &app{runTmuxEdit: tmuxedit.Run} }
-func main() { os.Exit(runMain(os.Args[1:], os.Stderr)) }
+func main() { os.Exit(newApp().runMain(os.Args[1:], os.Stderr)) }
// runMain parses flags from args and runs the tmux edit popup. It returns
// the process exit code; flag errors return 2 (matching stdlib convention),
// runtime failures return 1.
-func runMain(args []string, stderr io.Writer) int {
+func (a *app) runMain(args []string, stderr io.Writer) int {
defaultPath := appconfig.DefaultConfigPath()
fs := flag.NewFlagSet("hexai-tmux-edit", flag.ContinueOnError)
fs.SetOutput(stderr)
@@ -42,7 +45,7 @@ func runMain(args []string, stderr io.Writer) int {
}
opts := buildOptions(*configPath, *agent, *pane)
- if err := runTmuxEdit(opts); err != nil {
+ if err := a.runTmuxEdit(opts); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
diff --git a/cmd/hexai-tmux-edit/main_test.go b/cmd/hexai-tmux-edit/main_test.go
index 6881556..3171b86 100644
--- a/cmd/hexai-tmux-edit/main_test.go
+++ b/cmd/hexai-tmux-edit/main_test.go
@@ -30,17 +30,14 @@ func TestBuildOptions_TrimsWhitespace(t *testing.T) {
}
func TestRunTmuxEdit_Success(t *testing.T) {
- old := runTmuxEdit
- t.Cleanup(func() { runTmuxEdit = old })
-
var gotOpts tmuxedit.Options
- runTmuxEdit = func(opts tmuxedit.Options) error {
+ a := &app{runTmuxEdit: func(opts tmuxedit.Options) error {
gotOpts = opts
return nil
- }
+ }}
opts := buildOptions("/tmp/cfg.toml", "cursor", "%3")
- if err := runTmuxEdit(opts); err != nil {
+ if err := a.runTmuxEdit(opts); err != nil {
t.Fatalf("runTmuxEdit: %v", err)
}
if gotOpts.ConfigPath != "/tmp/cfg.toml" || gotOpts.Agent != "cursor" || gotOpts.Pane != "%3" {
@@ -49,13 +46,10 @@ func TestRunTmuxEdit_Success(t *testing.T) {
}
func TestRunTmuxEdit_Error(t *testing.T) {
- old := runTmuxEdit
- t.Cleanup(func() { runTmuxEdit = old })
-
wantErr := errors.New("tmux not found")
- runTmuxEdit = func(_ tmuxedit.Options) error { return wantErr }
+ a := &app{runTmuxEdit: func(_ tmuxedit.Options) error { return wantErr }}
- if err := runTmuxEdit(tmuxedit.Options{}); !errors.Is(err, wantErr) {
+ if err := a.runTmuxEdit(tmuxedit.Options{}); !errors.Is(err, wantErr) {
t.Fatalf("expected error, got: %v", err)
}
}
@@ -63,17 +57,14 @@ func TestRunTmuxEdit_Error(t *testing.T) {
// runMain happy path: flags parse, runTmuxEdit returns nil, exit code 0.
// We capture the resolved Options to confirm flags map onto fields correctly.
func TestRunMain_FlagsForwardedToTmuxedit(t *testing.T) {
- old := runTmuxEdit
- t.Cleanup(func() { runTmuxEdit = old })
-
var got tmuxedit.Options
- runTmuxEdit = func(opts tmuxedit.Options) error {
+ a := &app{runTmuxEdit: func(opts tmuxedit.Options) error {
got = opts
return nil
- }
+ }}
var stderr bytes.Buffer
- code := runMain([]string{"-config", " /tmp/cfg.toml ", "-agent", "claude", "-pane", "%9"}, &stderr)
+ code := a.runMain([]string{"-config", " /tmp/cfg.toml ", "-agent", "claude", "-pane", "%9"}, &stderr)
if code != 0 {
t.Fatalf("runMain code = %d, want 0", code)
}
@@ -88,12 +79,10 @@ func TestRunMain_FlagsForwardedToTmuxedit(t *testing.T) {
// runMain reports tmuxedit.Run failures by writing to stderr and returning 1
// — the production exit code that the shipped binary uses.
func TestRunMain_RunErrorReturnsOne(t *testing.T) {
- old := runTmuxEdit
- t.Cleanup(func() { runTmuxEdit = old })
- runTmuxEdit = func(tmuxedit.Options) error { return errors.New("boom") }
+ a := &app{runTmuxEdit: func(tmuxedit.Options) error { return errors.New("boom") }}
var stderr bytes.Buffer
- code := runMain(nil, &stderr)
+ code := a.runMain(nil, &stderr)
if code != 1 {
t.Fatalf("runMain code = %d, want 1", code)
}
@@ -105,16 +94,14 @@ func TestRunMain_RunErrorReturnsOne(t *testing.T) {
// Unknown flags must yield exit 2 (the convention used by stdlib `flag` when
// ExitOnError aborts) without ever invoking runTmuxEdit.
func TestRunMain_BadFlagReturnsTwo(t *testing.T) {
- old := runTmuxEdit
- t.Cleanup(func() { runTmuxEdit = old })
called := false
- runTmuxEdit = func(tmuxedit.Options) error {
+ a := &app{runTmuxEdit: func(tmuxedit.Options) error {
called = true
return nil
- }
+ }}
var stderr bytes.Buffer
- code := runMain([]string{"--no-such-flag"}, &stderr)
+ code := a.runMain([]string{"--no-such-flag"}, &stderr)
if code != 2 {
t.Fatalf("runMain code = %d, want 2", code)
}
diff --git a/internal/askcli/command_edit.go b/internal/askcli/command_edit.go
index a82d575..e2c2dbf 100644
--- a/internal/askcli/command_edit.go
+++ b/internal/askcli/command_edit.go
@@ -8,11 +8,11 @@ import (
"codeberg.org/snonux/hexai/internal/editor"
)
-// captureFromEditor opens the user's editor on a temporary file pre-filled with
+// editorCapture opens the user's editor on a temporary file pre-filled with
// the given initial content and returns its trimmed contents after the editor
// exits. ctx is forwarded to the editor subprocess so it can be cancelled with
-// the surrounding command. It is a variable so tests can stub it.
-var captureFromEditor = func(ctx context.Context, initial []byte) (string, error) {
+// the surrounding command.
+func editorCapture(ctx context.Context, initial []byte) (string, error) {
return editor.OpenTempAndEdit(ctx, initial)
}
@@ -23,7 +23,7 @@ func (d *Dispatcher) handleEdit(ctx context.Context, args []string, stdout, stde
if len(args) >= 2 {
return d.editTaskDescription(ctx, args[1], stdout, stderr)
}
- description, err := captureFromEditor(ctx, nil)
+ description, err := d.capture(ctx, nil)
if err != nil {
writeInfoError(stderr, err)
return 1, nil
@@ -44,7 +44,7 @@ func (d *Dispatcher) editTaskDescription(ctx context.Context, selector string, s
return code, nil
}
- description, err := captureFromEditor(ctx, []byte(tasks[0].Description))
+ description, err := d.capture(ctx, []byte(tasks[0].Description))
if err != nil {
writeInfoError(stderr, err)
return 1, nil
diff --git a/internal/askcli/command_edit_test.go b/internal/askcli/command_edit_test.go
index 4db19c1..91569c8 100644
--- a/internal/askcli/command_edit_test.go
+++ b/internal/askcli/command_edit_test.go
@@ -9,13 +9,10 @@ import (
"testing"
)
-func stubEditorCapture(t *testing.T, content string, err error) {
- t.Helper()
- old := captureFromEditor
- captureFromEditor = func(_ context.Context, initial []byte) (string, error) {
+func stubEditorCapture(d *Dispatcher, content string, err error) {
+ d.capture = func(_ context.Context, initial []byte) (string, error) {
return content, err
}
- t.Cleanup(func() { captureFromEditor = old })
}
func TestHandleEdit_Success(t *testing.T) {
@@ -26,15 +23,14 @@ func TestHandleEdit_Success(t *testing.T) {
{UUID: "existing-uuid", Alias: "0", CreatedAt: now},
},
})
- // editor.OpenTempAndEdit trims content, so mimic that here.
- stubEditorCapture(t, "Multi line\ntask description", nil)
-
var capturedArgs []string
d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
capturedArgs = args
_, _ = io.WriteString(stdout, "Created task abc-123-def.")
return 0, nil
}})
+ // editor.OpenTempAndEdit trims content, so mimic that here.
+ stubEditorCapture(d, "Multi line\ntask description", nil)
var stdout, stderr bytes.Buffer
code, _ := d.Dispatch(context.Background(), []string{"edit"}, nil, &stdout, &stderr)
@@ -58,15 +54,8 @@ func TestHandleEdit_ExistingTaskModifiesDescription(t *testing.T) {
},
})
- var initialContent []byte
- old := captureFromEditor
- captureFromEditor = func(_ context.Context, initial []byte) (string, error) {
- initialContent = initial
- return "updated description", nil
- }
- t.Cleanup(func() { captureFromEditor = old })
-
var capturedArgs []string
+ var initialContent []byte
d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
// First call: export to resolve selector. Subsequent: modify.
if len(args) >= 2 && args[1] == "export" {
@@ -76,6 +65,10 @@ func TestHandleEdit_ExistingTaskModifiesDescription(t *testing.T) {
capturedArgs = args
return 0, nil
}})
+ d.capture = func(_ context.Context, initial []byte) (string, error) {
+ initialContent = initial
+ return "updated description", nil
+ }
var stdout, stderr bytes.Buffer
code, _ := d.Dispatch(context.Background(), []string{"edit", "0"}, nil, &stdout, &stderr)
@@ -97,12 +90,11 @@ func TestHandleEdit_ExistingTaskModifiesDescription(t *testing.T) {
}
func TestHandleEdit_EmptyContentAborts(t *testing.T) {
- stubEditorCapture(t, "", nil)
-
d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
t.Fatalf("runner should not be called on empty content")
return 0, nil
}})
+ stubEditorCapture(d, "", nil)
var stdout, stderr bytes.Buffer
code, _ := d.Dispatch(context.Background(), []string{"edit"}, nil, &stdout, &stderr)
@@ -115,12 +107,11 @@ func TestHandleEdit_EmptyContentAborts(t *testing.T) {
}
func TestHandleEdit_EditorError(t *testing.T) {
- stubEditorCapture(t, "", errors.New("boom"))
-
d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
t.Fatalf("runner should not be called when editor fails")
return 0, nil
}})
+ stubEditorCapture(d, "", errors.New("boom"))
var stdout, stderr bytes.Buffer
code, _ := d.Dispatch(context.Background(), []string{"edit"}, nil, &stdout, &stderr)
diff --git a/internal/askcli/command_watch.go b/internal/askcli/command_watch.go
index 8d8e35f..0e0a678 100644
--- a/internal/askcli/command_watch.go
+++ b/internal/askcli/command_watch.go
@@ -30,7 +30,10 @@ func (t realWatchTicker) Stop() {
t.ticker.Stop()
}
-var newWatchTicker = func(interval time.Duration) watchTicker {
+// newRealWatchTicker is the default watchTicker factory wired into a
+// Dispatcher by NewDispatcher. It is injected as Dispatcher.newTicker so tests
+// can substitute a fake ticker instead of mutating package state.
+func newRealWatchTicker(interval time.Duration) watchTicker {
return realWatchTicker{ticker: time.NewTicker(interval)}
}
@@ -63,7 +66,7 @@ func (d *Dispatcher) handleWatch(ctx context.Context, args []string, stdout, std
return 1, nil
}
- ticker := newWatchTicker(watchInterval)
+ ticker := d.newTicker(watchInterval)
defer ticker.Stop()
var lastOutput []byte
diff --git a/internal/askcli/command_watch_test.go b/internal/askcli/command_watch_test.go
index abd74ff..7618060 100644
--- a/internal/askcli/command_watch_test.go
+++ b/internal/askcli/command_watch_test.go
@@ -27,14 +27,11 @@ func (t *fakeWatchTicker) Stop() {
func TestHandleWatch_ForwardsNonZeroCode(t *testing.T) {
ticks := make(chan time.Time)
- oldTicker := newWatchTicker
- newWatchTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} }
- t.Cleanup(func() { newWatchTicker = oldTicker })
-
ctx := context.Background()
d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
return 1, nil
}})
+ d.newTicker = func(time.Durati