Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# Exclude example tasks and test files from coverage
ignore:
- "tasks/example_*.go"
- "**/example_*.go"
- "**/*_test.go"
- "mocks/"
- ".github/"
Expand Down
20 changes: 19 additions & 1 deletion action.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,25 @@ func EntityValue(gc *GlobalContext, entityType, id, key string) (interface{}, er
}
return nil, fmt.Errorf("task '%s' not found in context", id)
}
return TaskOutputFieldAs[interface{}](gc, id, key)
// Try TaskOutputs key first
if v, err := TaskOutputFieldAs[interface{}](gc, id, key); err == nil {
return v, nil
}
// Fallback to TaskResults if present and result is a map
gc.mu.RLock()
rp, exists := gc.TaskResults[id]
gc.mu.RUnlock()
if exists && rp != nil {
res := rp.GetResult()
if m, ok := res.(map[string]interface{}); ok {
if val, ok := m[key]; ok {
return val, nil
}
return nil, fmt.Errorf("EntityValue: result key '%s' not found in task '%s'", key, id)
}
return nil, fmt.Errorf("EntityValue: task '%s' result is not a map, cannot extract key '%s'", id, key)
}
return nil, fmt.Errorf("task '%s' not found in context", id)
default:
return nil, fmt.Errorf("invalid entity type '%s'", entityType)
}
Expand Down
15 changes: 15 additions & 0 deletions action_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,21 @@ func TestGenerateIDFromName(t *testing.T) {
}
}

// New tests to cover SetID and GetName methods
func TestAction_SetIDAndGetName(t *testing.T) {
a := &Action[*MockAction]{
Wrapped: &MockAction{},
}
// SetID should update ID
a.SetID("manual-id")
assert.Equal(t, "manual-id", a.GetID())
// With empty Name, GetName should fallback to ID
assert.Equal(t, "manual-id", a.GetName())
// When Name is set, GetName should return it
a.Name = "Friendly Name"
assert.Equal(t, "Friendly Name", a.GetName())
}

// Helper types for testing
type testStringer struct{}

Expand Down
95 changes: 95 additions & 0 deletions parameters_resolve_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package task_engine_test

import (
"context"
"testing"

engine "github.com/ndizazzo/task-engine"
)

// minimal ResultProvider for tests
type rp struct{ v interface{} }

func (p rp) GetResult() interface{} { return p.v }
func (p rp) GetError() error { return nil }

func TestParameterResolvers_ResultProviders(t *testing.T) {
gc := engine.NewGlobalContext()

// Prepare action result (map) and task result (map)
gc.StoreActionResult("actR", rp{v: map[string]interface{}{"sum": 10, "name": "demo"}})
gc.StoreTaskResult("taskR", rp{v: map[string]interface{}{"ok": true, "n": 3}})

// ActionResultParameter full result
arp := engine.ActionResult("actR")
if v, err := arp.Resolve(context.Background(), gc); err != nil {
t.Fatalf("ActionResult Resolve err: %v", err)
} else if m, ok := v.(map[string]interface{}); !ok || m["sum"].(int) != 10 {
t.Fatalf("unexpected action result: %v", v)
}
// ActionResultParameter by key
arpk := engine.ActionResultField("actR", "name")
if v, err := arpk.Resolve(context.Background(), gc); err != nil || v.(string) != "demo" {
t.Fatalf("unexpected action result key: v=%v err=%v", v, err)
}

// TaskResultParameter full result
trp := engine.TaskResult("taskR")
if v, err := trp.Resolve(context.Background(), gc); err != nil {
t.Fatalf("TaskResult Resolve err: %v", err)
} else if m, ok := v.(map[string]interface{}); !ok || m["ok"].(bool) != true {
t.Fatalf("unexpected task result: %v", v)
}
// TaskResultParameter by key
trpk := engine.TaskResultField("taskR", "n")
if v, err := trpk.Resolve(context.Background(), gc); err != nil || v.(int) != 3 {
t.Fatalf("unexpected task result key: v=%v err=%v", v, err)
}
}

func TestEntityOutputParameter_FallbackToResults(t *testing.T) {
gc := engine.NewGlobalContext()
// Only a result is present (no output)
gc.StoreActionResult("A", rp{v: map[string]interface{}{"k": 1}})
gc.StoreTaskResult("T", rp{v: map[string]interface{}{"s": "ok"}})

// Action entity, full result
p1 := engine.EntityOutput("action", "A")
if v, err := p1.Resolve(context.Background(), gc); err != nil {
t.Fatalf("EntityOutput(action) err: %v", err)
} else if m, ok := v.(map[string]interface{}); !ok || m["k"].(int) != 1 {
t.Fatalf("unexpected value: %v", v)
}
// Action entity by key
p1k := engine.EntityOutputField("action", "A", "k")
if v, err := p1k.Resolve(context.Background(), gc); err != nil || v.(int) != 1 {
t.Fatalf("unexpected key value: %v err=%v", v, err)
}

// Task entity, full result
p2 := engine.EntityOutput("task", "T")
if v, err := p2.Resolve(context.Background(), gc); err != nil {
t.Fatalf("EntityOutput(task) err: %v", err)
} else if m, ok := v.(map[string]interface{}); !ok || m["s"].(string) != "ok" {
t.Fatalf("unexpected value: %v", v)
}
// Task entity by key
p2k := engine.EntityOutputField("task", "T", "s")
if v, err := p2k.Resolve(context.Background(), gc); err != nil || v.(string) != "ok" {
t.Fatalf("unexpected key value: %v err=%v", v, err)
}
}

func TestResolveAs_GenericAdditional(t *testing.T) {
gc := engine.NewGlobalContext()
gc.StoreActionOutput("actX", map[string]interface{}{"flag": true, "nums": []string{"a", "b"}})

b, err := engine.ResolveAs[bool](context.Background(), engine.ActionOutputField("actX", "flag"), gc)
if err != nil || b != true {
t.Fatalf("expected true, got %v err=%v", b, err)
}
sl, err := engine.ResolveAs[[]string](context.Background(), engine.ActionOutputField("actX", "nums"), gc)
if err != nil || len(sl) != 2 || sl[0] != "a" {
t.Fatalf("unexpected slice: %v err=%v", sl, err)
}
}
201 changes: 192 additions & 9 deletions task_engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,7 @@ const (
LongActionTime = 500 * time.Millisecond
)

// NewDiscardLogger creates a new logger that discards all output
// This is useful for tests to prevent log output from cluttering test results
func NewDiscardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// duplicate NewDiscardLogger removed (defined earlier in file)

type TestAction struct {
task_engine.BaseAction
Expand Down Expand Up @@ -86,11 +82,25 @@ type testResultProvider struct{ v interface{} }
func (p testResultProvider) GetResult() interface{} { return p.v }
func (p testResultProvider) GetError() error { return nil }

func (a *AfterExecuteFailingAction) AfterExecute(ctx context.Context) error {
if a.ShouldFailAfter {
return errors.New("simulated AfterExecute failure")
// CancelAwareAction returns context error if canceled, otherwise completes after Delay
type CancelAwareAction struct {
task_engine.BaseAction
Delay time.Duration
}

func (a *CancelAwareAction) Execute(ctx context.Context) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(a.Delay):
return nil
}
return nil
}

// NewDiscardLogger creates a new logger that discards all output
// This is useful for tests to prevent log output from cluttering test results
func NewDiscardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}

var (
Expand Down Expand Up @@ -374,6 +384,179 @@ func TestResolveAsGeneric(t *testing.T) {
}
}

func TestEntityValueNegativePaths(t *testing.T) {
gc := task_engine.NewGlobalContext()

if _, err := task_engine.EntityValue(gc, "invalid", "id", ""); err == nil {
t.Fatalf("expected error for invalid entity type")
}
if _, err := task_engine.EntityValue(gc, "action", "missing", ""); err == nil {
t.Fatalf("expected error for missing action")
}
gc.StoreActionOutput("a1", map[string]interface{}{"k": 1})
if _, err := task_engine.ActionOutputFieldAs[string](gc, "a1", "k"); err == nil {
t.Fatalf("expected type error for wrong cast")
}
}

func TestResolveAsNegative(t *testing.T) {
gc := task_engine.NewGlobalContext()
gc.StoreActionOutput("a", map[string]interface{}{"x": "str"})
// wrong type
if _, err := task_engine.ResolveAs[int](context.Background(), task_engine.ActionOutputField("a", "x"), gc); err == nil {
t.Fatalf("expected type error for ResolveAs")
}
}

func TestIDHelpers(t *testing.T) {
if out := task_engine.SanitizeIDPart(" Hello/World _! "); out == "" {
t.Fatalf("expected sanitized non-empty id")
}
id := task_engine.BuildActionID("prefix", " Part A ", "B/C")
if id == "" || id == "action-action" {
t.Fatalf("unexpected id: %s", id)
}
}

// Task cancellation should still store task output and task result
func TestTaskCancellationStoresOutputAndResult(t *testing.T) {
logger := NewDiscardLogger()
gc := task_engine.NewGlobalContext()

// Task with a quick action and a cancel-aware long-running action
task := &task_engine.Task{
ID: "cancel-task",
Name: "Cancellation Test",
Actions: []task_engine.ActionWrapper{
&task_engine.Action[*DelayAction]{
ID: "quick",
Wrapped: &DelayAction{BaseAction: task_engine.BaseAction{Logger: logger}, Delay: 1 * time.Millisecond},
Logger: logger,
},
&task_engine.Action[*CancelAwareAction]{
ID: "slow",
Wrapped: &CancelAwareAction{BaseAction: task_engine.BaseAction{Logger: logger}, Delay: 2 * time.Second},
Logger: logger,
},
},
Logger: logger,
}

ctx, cancel := context.WithCancel(context.Background())
go func() {
// cancel shortly after start
time.Sleep(5 * time.Millisecond)
cancel()
}()
_ = task.RunWithContext(ctx, gc)

// Verify task output and result stored
if _, ok := gc.TaskOutputs[task.ID]; !ok {
t.Fatalf("expected TaskOutputs to contain task output on cancellation")
}
if _, ok := gc.TaskResults[task.ID]; !ok {
t.Fatalf("expected TaskResults to contain task result provider on cancellation")
}
// Check outputs map for success=false
out := gc.TaskOutputs[task.ID].(map[string]interface{})
if out["success"].(bool) {
t.Fatalf("expected success=false on cancellation")
}
}

// ResultBuilder error should set task error and mark success=false in outputs
func TestTaskResultBuilderErrorPath(t *testing.T) {
logger := NewDiscardLogger()
gc := task_engine.NewGlobalContext()

errSentinel := errors.New("builder failed")
builderTask := &task_engine.Task{
ID: "builder-error",
Name: "Builder Error",
Actions: []task_engine.ActionWrapper{
&task_engine.Action[*DelayAction]{ID: "noop", Wrapped: &DelayAction{}, Logger: logger},
},
Logger: logger,
ResultBuilder: func(ctx *task_engine.TaskContext) (interface{}, error) {
return nil, errSentinel
},
}

_ = builderTask.RunWithContext(context.Background(), gc)
out, ok := gc.TaskOutputs[builderTask.ID]
if !ok {
t.Fatalf("expected TaskOutputs to contain output")
}
outMap := out.(map[string]interface{})
if outMap["success"].(bool) {
t.Fatalf("expected success=false when builder fails")
}
// Result should be from task provider with error surfaced in GetResult map
res, ok := task_engine.TaskResultAs[map[string]interface{}](gc, builderTask.ID)
if !ok {
t.Fatalf("expected typed task result from task provider")
}
if res["success"].(bool) {
t.Fatalf("expected task result success=false when builder fails")
}
}

// Typed helper does not fallback from outputs to results for tasks; verify error
func TestTypedHelperNoFallbackForTaskOutputFieldAs(t *testing.T) {
gc := task_engine.NewGlobalContext()
// Only set task result, no task output
gc.StoreTaskResult("t1", testResultProvider{v: map[string]interface{}{"v": 1}})
if _, err := task_engine.TaskOutputFieldAs[int](gc, "t1", "v"); err == nil {
t.Fatalf("expected error since TaskOutputFieldAs should not fallback to results")
}
// But EntityValue should fallback to results and succeed (full result)
if v, err := task_engine.EntityValue(gc, "task", "t1", ""); err != nil {
t.Fatalf("expected EntityValue to return fallback result, err=%v", err)
} else {
if m, ok := v.(map[string]interface{}); !ok || m["v"].(int) != 1 {
t.Fatalf("unexpected result fallback: %v", v)
}
}
// And with a key, EntityValue should read from result map
if v, err := task_engine.EntityValue(gc, "task", "t1", "v"); err != nil || v.(int) != 1 {
t.Fatalf("expected EntityValue with key to read from result map, got %v, err=%v", v, err)
}
}

// TaskManager timeout and ResetGlobalContext behavior
func TestTaskManagerTimeoutAndResetGlobalContext(t *testing.T) {
logger := NewDiscardLogger()
tm := task_engine.NewTaskManager(logger)

// Long-running task
task := &task_engine.Task{
ID: "timeout-task",
Name: "Timeout Task",
Actions: []task_engine.ActionWrapper{
&task_engine.Action[*DelayAction]{ID: "slow", Wrapped: &DelayAction{Delay: 2 * time.Second}, Logger: logger},
},
Logger: logger,
}
_ = tm.AddTask(task)
_ = tm.RunTask("timeout-task")
// Expect timeout quickly
if err := tm.WaitForAllTasksToComplete(10 * time.Millisecond); err == nil {
t.Fatalf("expected timeout error")
}

// Store something in current global context
gc := tm.GetGlobalContext()
gc.StoreActionOutput("a", "x")
// Reset and verify cleared
tm.ResetGlobalContext()
gc2 := tm.GetGlobalContext()
if gc2 == gc || len(gc2.ActionOutputs) != 0 || len(gc2.TaskOutputs) != 0 || len(gc2.ActionResults) != 0 || len(gc2.TaskResults) != 0 {
t.Fatalf("expected a fresh global context after reset")
}
// Stop to clean up
_ = tm.StopTask("timeout-task")
}

func TestTaskWithParameterPassing(t *testing.T) {
t.Run("TaskExecutionWithGlobalContext", func(t *testing.T) {
logger := NewDiscardLogger()
Expand Down
Loading
Loading