Skip to content

Commit 307e681

Browse files
authored
test: add cancellation, builder error, typed-helper fallback, manager timeout/reset tests; adjust EntityValue task key fallback (#14)
1 parent 7576292 commit 307e681

6 files changed

Lines changed: 330 additions & 14 deletions

File tree

.codecov.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# Exclude example tasks and test files from coverage
55
ignore:
66
- "tasks/example_*.go"
7+
- "**/example_*.go"
78
- "**/*_test.go"
89
- "mocks/"
910
- ".github/"

action.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,25 @@ func EntityValue(gc *GlobalContext, entityType, id, key string) (interface{}, er
279279
}
280280
return nil, fmt.Errorf("task '%s' not found in context", id)
281281
}
282-
return TaskOutputFieldAs[interface{}](gc, id, key)
282+
// Try TaskOutputs key first
283+
if v, err := TaskOutputFieldAs[interface{}](gc, id, key); err == nil {
284+
return v, nil
285+
}
286+
// Fallback to TaskResults if present and result is a map
287+
gc.mu.RLock()
288+
rp, exists := gc.TaskResults[id]
289+
gc.mu.RUnlock()
290+
if exists && rp != nil {
291+
res := rp.GetResult()
292+
if m, ok := res.(map[string]interface{}); ok {
293+
if val, ok := m[key]; ok {
294+
return val, nil
295+
}
296+
return nil, fmt.Errorf("EntityValue: result key '%s' not found in task '%s'", key, id)
297+
}
298+
return nil, fmt.Errorf("EntityValue: task '%s' result is not a map, cannot extract key '%s'", id, key)
299+
}
300+
return nil, fmt.Errorf("task '%s' not found in context", id)
283301
default:
284302
return nil, fmt.Errorf("invalid entity type '%s'", entityType)
285303
}

action_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,21 @@ func TestGenerateIDFromName(t *testing.T) {
731731
}
732732
}
733733

734+
// New tests to cover SetID and GetName methods
735+
func TestAction_SetIDAndGetName(t *testing.T) {
736+
a := &Action[*MockAction]{
737+
Wrapped: &MockAction{},
738+
}
739+
// SetID should update ID
740+
a.SetID("manual-id")
741+
assert.Equal(t, "manual-id", a.GetID())
742+
// With empty Name, GetName should fallback to ID
743+
assert.Equal(t, "manual-id", a.GetName())
744+
// When Name is set, GetName should return it
745+
a.Name = "Friendly Name"
746+
assert.Equal(t, "Friendly Name", a.GetName())
747+
}
748+
734749
// Helper types for testing
735750
type testStringer struct{}
736751

parameters_resolve_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package task_engine_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
engine "github.com/ndizazzo/task-engine"
8+
)
9+
10+
// minimal ResultProvider for tests
11+
type rp struct{ v interface{} }
12+
13+
func (p rp) GetResult() interface{} { return p.v }
14+
func (p rp) GetError() error { return nil }
15+
16+
func TestParameterResolvers_ResultProviders(t *testing.T) {
17+
gc := engine.NewGlobalContext()
18+
19+
// Prepare action result (map) and task result (map)
20+
gc.StoreActionResult("actR", rp{v: map[string]interface{}{"sum": 10, "name": "demo"}})
21+
gc.StoreTaskResult("taskR", rp{v: map[string]interface{}{"ok": true, "n": 3}})
22+
23+
// ActionResultParameter full result
24+
arp := engine.ActionResult("actR")
25+
if v, err := arp.Resolve(context.Background(), gc); err != nil {
26+
t.Fatalf("ActionResult Resolve err: %v", err)
27+
} else if m, ok := v.(map[string]interface{}); !ok || m["sum"].(int) != 10 {
28+
t.Fatalf("unexpected action result: %v", v)
29+
}
30+
// ActionResultParameter by key
31+
arpk := engine.ActionResultField("actR", "name")
32+
if v, err := arpk.Resolve(context.Background(), gc); err != nil || v.(string) != "demo" {
33+
t.Fatalf("unexpected action result key: v=%v err=%v", v, err)
34+
}
35+
36+
// TaskResultParameter full result
37+
trp := engine.TaskResult("taskR")
38+
if v, err := trp.Resolve(context.Background(), gc); err != nil {
39+
t.Fatalf("TaskResult Resolve err: %v", err)
40+
} else if m, ok := v.(map[string]interface{}); !ok || m["ok"].(bool) != true {
41+
t.Fatalf("unexpected task result: %v", v)
42+
}
43+
// TaskResultParameter by key
44+
trpk := engine.TaskResultField("taskR", "n")
45+
if v, err := trpk.Resolve(context.Background(), gc); err != nil || v.(int) != 3 {
46+
t.Fatalf("unexpected task result key: v=%v err=%v", v, err)
47+
}
48+
}
49+
50+
func TestEntityOutputParameter_FallbackToResults(t *testing.T) {
51+
gc := engine.NewGlobalContext()
52+
// Only a result is present (no output)
53+
gc.StoreActionResult("A", rp{v: map[string]interface{}{"k": 1}})
54+
gc.StoreTaskResult("T", rp{v: map[string]interface{}{"s": "ok"}})
55+
56+
// Action entity, full result
57+
p1 := engine.EntityOutput("action", "A")
58+
if v, err := p1.Resolve(context.Background(), gc); err != nil {
59+
t.Fatalf("EntityOutput(action) err: %v", err)
60+
} else if m, ok := v.(map[string]interface{}); !ok || m["k"].(int) != 1 {
61+
t.Fatalf("unexpected value: %v", v)
62+
}
63+
// Action entity by key
64+
p1k := engine.EntityOutputField("action", "A", "k")
65+
if v, err := p1k.Resolve(context.Background(), gc); err != nil || v.(int) != 1 {
66+
t.Fatalf("unexpected key value: %v err=%v", v, err)
67+
}
68+
69+
// Task entity, full result
70+
p2 := engine.EntityOutput("task", "T")
71+
if v, err := p2.Resolve(context.Background(), gc); err != nil {
72+
t.Fatalf("EntityOutput(task) err: %v", err)
73+
} else if m, ok := v.(map[string]interface{}); !ok || m["s"].(string) != "ok" {
74+
t.Fatalf("unexpected value: %v", v)
75+
}
76+
// Task entity by key
77+
p2k := engine.EntityOutputField("task", "T", "s")
78+
if v, err := p2k.Resolve(context.Background(), gc); err != nil || v.(string) != "ok" {
79+
t.Fatalf("unexpected key value: %v err=%v", v, err)
80+
}
81+
}
82+
83+
func TestResolveAs_GenericAdditional(t *testing.T) {
84+
gc := engine.NewGlobalContext()
85+
gc.StoreActionOutput("actX", map[string]interface{}{"flag": true, "nums": []string{"a", "b"}})
86+
87+
b, err := engine.ResolveAs[bool](context.Background(), engine.ActionOutputField("actX", "flag"), gc)
88+
if err != nil || b != true {
89+
t.Fatalf("expected true, got %v err=%v", b, err)
90+
}
91+
sl, err := engine.ResolveAs[[]string](context.Background(), engine.ActionOutputField("actX", "nums"), gc)
92+
if err != nil || len(sl) != 2 || sl[0] != "a" {
93+
t.Fatalf("unexpected slice: %v err=%v", sl, err)
94+
}
95+
}

task_engine_test.go

Lines changed: 192 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,7 @@ const (
1919
LongActionTime = 500 * time.Millisecond
2020
)
2121

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

2824
type TestAction struct {
2925
task_engine.BaseAction
@@ -86,11 +82,25 @@ type testResultProvider struct{ v interface{} }
8682
func (p testResultProvider) GetResult() interface{} { return p.v }
8783
func (p testResultProvider) GetError() error { return nil }
8884

89-
func (a *AfterExecuteFailingAction) AfterExecute(ctx context.Context) error {
90-
if a.ShouldFailAfter {
91-
return errors.New("simulated AfterExecute failure")
85+
// CancelAwareAction returns context error if canceled, otherwise completes after Delay
86+
type CancelAwareAction struct {
87+
task_engine.BaseAction
88+
Delay time.Duration
89+
}
90+
91+
func (a *CancelAwareAction) Execute(ctx context.Context) error {
92+
select {
93+
case <-ctx.Done():
94+
return ctx.Err()
95+
case <-time.After(a.Delay):
96+
return nil
9297
}
93-
return nil
98+
}
99+
100+
// NewDiscardLogger creates a new logger that discards all output
101+
// This is useful for tests to prevent log output from cluttering test results
102+
func NewDiscardLogger() *slog.Logger {
103+
return slog.New(slog.NewTextHandler(io.Discard, nil))
94104
}
95105

96106
var (
@@ -374,6 +384,179 @@ func TestResolveAsGeneric(t *testing.T) {
374384
}
375385
}
376386

387+
func TestEntityValueNegativePaths(t *testing.T) {
388+
gc := task_engine.NewGlobalContext()
389+
390+
if _, err := task_engine.EntityValue(gc, "invalid", "id", ""); err == nil {
391+
t.Fatalf("expected error for invalid entity type")
392+
}
393+
if _, err := task_engine.EntityValue(gc, "action", "missing", ""); err == nil {
394+
t.Fatalf("expected error for missing action")
395+
}
396+
gc.StoreActionOutput("a1", map[string]interface{}{"k": 1})
397+
if _, err := task_engine.ActionOutputFieldAs[string](gc, "a1", "k"); err == nil {
398+
t.Fatalf("expected type error for wrong cast")
399+
}
400+
}
401+
402+
func TestResolveAsNegative(t *testing.T) {
403+
gc := task_engine.NewGlobalContext()
404+
gc.StoreActionOutput("a", map[string]interface{}{"x": "str"})
405+
// wrong type
406+
if _, err := task_engine.ResolveAs[int](context.Background(), task_engine.ActionOutputField("a", "x"), gc); err == nil {
407+
t.Fatalf("expected type error for ResolveAs")
408+
}
409+
}
410+
411+
func TestIDHelpers(t *testing.T) {
412+
if out := task_engine.SanitizeIDPart(" Hello/World _! "); out == "" {
413+
t.Fatalf("expected sanitized non-empty id")
414+
}
415+
id := task_engine.BuildActionID("prefix", " Part A ", "B/C")
416+
if id == "" || id == "action-action" {
417+
t.Fatalf("unexpected id: %s", id)
418+
}
419+
}
420+
421+
// Task cancellation should still store task output and task result
422+
func TestTaskCancellationStoresOutputAndResult(t *testing.T) {
423+
logger := NewDiscardLogger()
424+
gc := task_engine.NewGlobalContext()
425+
426+
// Task with a quick action and a cancel-aware long-running action
427+
task := &task_engine.Task{
428+
ID: "cancel-task",
429+
Name: "Cancellation Test",
430+
Actions: []task_engine.ActionWrapper{
431+
&task_engine.Action[*DelayAction]{
432+
ID: "quick",
433+
Wrapped: &DelayAction{BaseAction: task_engine.BaseAction{Logger: logger}, Delay: 1 * time.Millisecond},
434+
Logger: logger,
435+
},
436+
&task_engine.Action[*CancelAwareAction]{
437+
ID: "slow",
438+
Wrapped: &CancelAwareAction{BaseAction: task_engine.BaseAction{Logger: logger}, Delay: 2 * time.Second},
439+
Logger: logger,
440+
},
441+
},
442+
Logger: logger,
443+
}
444+
445+
ctx, cancel := context.WithCancel(context.Background())
446+
go func() {
447+
// cancel shortly after start
448+
time.Sleep(5 * time.Millisecond)
449+
cancel()
450+
}()
451+
_ = task.RunWithContext(ctx, gc)
452+
453+
// Verify task output and result stored
454+
if _, ok := gc.TaskOutputs[task.ID]; !ok {
455+
t.Fatalf("expected TaskOutputs to contain task output on cancellation")
456+
}
457+
if _, ok := gc.TaskResults[task.ID]; !ok {
458+
t.Fatalf("expected TaskResults to contain task result provider on cancellation")
459+
}
460+
// Check outputs map for success=false
461+
out := gc.TaskOutputs[task.ID].(map[string]interface{})
462+
if out["success"].(bool) {
463+
t.Fatalf("expected success=false on cancellation")
464+
}
465+
}
466+
467+
// ResultBuilder error should set task error and mark success=false in outputs
468+
func TestTaskResultBuilderErrorPath(t *testing.T) {
469+
logger := NewDiscardLogger()
470+
gc := task_engine.NewGlobalContext()
471+
472+
errSentinel := errors.New("builder failed")
473+
builderTask := &task_engine.Task{
474+
ID: "builder-error",
475+
Name: "Builder Error",
476+
Actions: []task_engine.ActionWrapper{
477+
&task_engine.Action[*DelayAction]{ID: "noop", Wrapped: &DelayAction{}, Logger: logger},
478+
},
479+
Logger: logger,
480+
ResultBuilder: func(ctx *task_engine.TaskContext) (interface{}, error) {
481+
return nil, errSentinel
482+
},
483+
}
484+
485+
_ = builderTask.RunWithContext(context.Background(), gc)
486+
out, ok := gc.TaskOutputs[builderTask.ID]
487+
if !ok {
488+
t.Fatalf("expected TaskOutputs to contain output")
489+
}
490+
outMap := out.(map[string]interface{})
491+
if outMap["success"].(bool) {
492+
t.Fatalf("expected success=false when builder fails")
493+
}
494+
// Result should be from task provider with error surfaced in GetResult map
495+
res, ok := task_engine.TaskResultAs[map[string]interface{}](gc, builderTask.ID)
496+
if !ok {
497+
t.Fatalf("expected typed task result from task provider")
498+
}
499+
if res["success"].(bool) {
500+
t.Fatalf("expected task result success=false when builder fails")
501+
}
502+
}
503+
504+
// Typed helper does not fallback from outputs to results for tasks; verify error
505+
func TestTypedHelperNoFallbackForTaskOutputFieldAs(t *testing.T) {
506+
gc := task_engine.NewGlobalContext()
507+
// Only set task result, no task output
508+
gc.StoreTaskResult("t1", testResultProvider{v: map[string]interface{}{"v": 1}})
509+
if _, err := task_engine.TaskOutputFieldAs[int](gc, "t1", "v"); err == nil {
510+
t.Fatalf("expected error since TaskOutputFieldAs should not fallback to results")
511+
}
512+
// But EntityValue should fallback to results and succeed (full result)
513+
if v, err := task_engine.EntityValue(gc, "task", "t1", ""); err != nil {
514+
t.Fatalf("expected EntityValue to return fallback result, err=%v", err)
515+
} else {
516+
if m, ok := v.(map[string]interface{}); !ok || m["v"].(int) != 1 {
517+
t.Fatalf("unexpected result fallback: %v", v)
518+
}
519+
}
520+
// And with a key, EntityValue should read from result map
521+
if v, err := task_engine.EntityValue(gc, "task", "t1", "v"); err != nil || v.(int) != 1 {
522+
t.Fatalf("expected EntityValue with key to read from result map, got %v, err=%v", v, err)
523+
}
524+
}
525+
526+
// TaskManager timeout and ResetGlobalContext behavior
527+
func TestTaskManagerTimeoutAndResetGlobalContext(t *testing.T) {
528+
logger := NewDiscardLogger()
529+
tm := task_engine.NewTaskManager(logger)
530+
531+
// Long-running task
532+
task := &task_engine.Task{
533+
ID: "timeout-task",
534+
Name: "Timeout Task",
535+
Actions: []task_engine.ActionWrapper{
536+
&task_engine.Action[*DelayAction]{ID: "slow", Wrapped: &DelayAction{Delay: 2 * time.Second}, Logger: logger},
537+
},
538+
Logger: logger,
539+
}
540+
_ = tm.AddTask(task)
541+
_ = tm.RunTask("timeout-task")
542+
// Expect timeout quickly
543+
if err := tm.WaitForAllTasksToComplete(10 * time.Millisecond); err == nil {
544+
t.Fatalf("expected timeout error")
545+
}
546+
547+
// Store something in current global context
548+
gc := tm.GetGlobalContext()
549+
gc.StoreActionOutput("a", "x")
550+
// Reset and verify cleared
551+
tm.ResetGlobalContext()
552+
gc2 := tm.GetGlobalContext()
553+
if gc2 == gc || len(gc2.ActionOutputs) != 0 || len(gc2.TaskOutputs) != 0 || len(gc2.ActionResults) != 0 || len(gc2.TaskResults) != 0 {
554+
t.Fatalf("expected a fresh global context after reset")
555+
}
556+
// Stop to clean up
557+
_ = tm.StopTask("timeout-task")
558+
}
559+
377560
func TestTaskWithParameterPassing(t *testing.T) {
378561
t.Run("TaskExecutionWithGlobalContext", func(t *testing.T) {
379562
logger := NewDiscardLogger()

0 commit comments

Comments
 (0)