From 9ea0e7df64c6d9d63edb4c05ebc4e59132425dd7 Mon Sep 17 00:00:00 2001 From: hovsep Date: Mon, 16 Sep 2024 04:11:48 +0300 Subject: [PATCH] FMesh: add tests --- component/activation_result.go | 7 +- component/component.go | 10 +- component/component_test.go | 8 +- cycle/result.go | 9 +- cycle/result_test.go | 19 +- errors.go | 6 - fmesh.go | 14 +- fmesh_test.go | 374 +++++++++++++++++++++++++++++++-- port/port.go | 6 +- 9 files changed, 399 insertions(+), 54 deletions(-) diff --git a/component/activation_result.go b/component/activation_result.go index b4269ee..10cc6a5 100644 --- a/component/activation_result.go +++ b/component/activation_result.go @@ -1,5 +1,7 @@ package component +import "fmt" + // ActivationResult defines the result (possibly an error) of the activation of given component in given cycle type ActivationResult struct { componentName string @@ -81,7 +83,10 @@ func (c *Component) newActivationCodeWaitingForInput() *ActivationResult { // newActivationCodeReturnedError builds a specific activation result func (c *Component) newActivationCodeReturnedError(err error) *ActivationResult { - return NewActivationResult(c.Name()).SetActivated(true).WithActivationCode(ActivationCodeReturnedError).WithError(err) + return NewActivationResult(c.Name()). + SetActivated(true). + WithActivationCode(ActivationCodeReturnedError). + WithError(fmt.Errorf("component returned an error: %w", err)) } // newActivationCodePanicked builds a specific activation result diff --git a/component/component.go b/component/component.go index 51bdcf6..e5a86e8 100644 --- a/component/component.go +++ b/component/component.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "github.com/hovsep/fmesh/port" - "runtime/debug" ) type ActivationFunc func(inputs port.Ports, outputs port.Ports) error @@ -91,12 +90,9 @@ func (c *Component) hasActivationFunction() bool { func (c *Component) MaybeActivate() (activationResult *ActivationResult) { defer func() { if r := recover(); r != nil { - errorFormat := "panicked with: %v, stacktrace: %s" - if _, ok := r.(error); ok { - errorFormat = "panicked with: %w, stacktrace: %s" - } - //TODO: add custom error - activationResult = c.newActivationCodePanicked(fmt.Errorf(errorFormat, r, debug.Stack())) + //Clear inputs and exit + c.inputs.ClearSignal() + activationResult = c.newActivationCodePanicked(fmt.Errorf("panicked with: %v", r)) } }() diff --git a/component/component_test.go b/component/component_test.go index a9efa80..af7e4c2 100644 --- a/component/component_test.go +++ b/component/component_test.go @@ -489,7 +489,7 @@ func TestComponent_Activate(t *testing.T) { wantActivationResult: NewActivationResult("c1"). SetActivated(true). WithActivationCode(ActivationCodeReturnedError). - WithError(errors.New("failed to activate component: test error")), + WithError(errors.New("component returned an error: test error")), }, { name: "activated without error", @@ -556,10 +556,10 @@ func TestComponent_Activate(t *testing.T) { assert.Equal(t, got.Activated(), tt.wantActivationResult.Activated()) assert.Equal(t, got.ComponentName(), tt.wantActivationResult.ComponentName()) assert.Equal(t, got.Code(), tt.wantActivationResult.Code()) - if !tt.wantActivationResult.HasError() { - assert.False(t, got.HasError()) + if tt.wantActivationResult.HasError() { + assert.EqualError(t, got.Error(), tt.wantActivationResult.Error().Error()) } else { - assert.ErrorContains(t, got.Error(), tt.wantActivationResult.Error().Error()) + assert.False(t, got.HasError()) } }) diff --git a/cycle/result.go b/cycle/result.go index 6d608b7..a9e14ee 100644 --- a/cycle/result.go +++ b/cycle/result.go @@ -79,7 +79,10 @@ func (cycleResult *Result) HasActivatedComponents() bool { return false } -// Add adds a cycle result to existing collection -func (cycleResults Results) Add(cycleResult *Result) Results { - return append(cycleResults, cycleResult) +// Add adds cycle results to existing collection +func (cycleResults Results) Add(newCycleResults ...*Result) Results { + for _, cycleResult := range newCycleResults { + cycleResults = append(cycleResults, cycleResult) + } + return cycleResults } diff --git a/cycle/result_test.go b/cycle/result_test.go index 273818b..03c2e10 100644 --- a/cycle/result_test.go +++ b/cycle/result_test.go @@ -9,7 +9,7 @@ import ( func TestResults_Add(t *testing.T) { type args struct { - cycleResult *Result + cycleResults []*Result } tests := []struct { name string @@ -21,11 +21,24 @@ func TestResults_Add(t *testing.T) { name: "happy path", cycleResults: NewResults(), args: args{ - cycleResult: NewResult().SetCycleNumber(1).WithActivationResults(component.NewActivationResult("c1").SetActivated(true)), + cycleResults: []*Result{ + NewResult(). + SetCycleNumber(1). + WithActivationResults(component.NewActivationResult("c1").SetActivated(false)), + NewResult(). + SetCycleNumber(2). + WithActivationResults(component.NewActivationResult("c1").SetActivated(true)), + }, }, want: Results{ { cycleNumber: 1, + activationResults: component.ActivationResults{ + "c1": component.NewActivationResult("c1").SetActivated(false), + }, + }, + { + cycleNumber: 2, activationResults: component.ActivationResults{ "c1": component.NewActivationResult("c1").SetActivated(true), }, @@ -35,7 +48,7 @@ func TestResults_Add(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := tt.cycleResults.Add(tt.args.cycleResult); !reflect.DeepEqual(got, tt.want) { + if got := tt.cycleResults.Add(tt.args.cycleResults...); !reflect.DeepEqual(got, tt.want) { t.Errorf("Add() = %v, want %v", got, tt.want) } }) diff --git a/errors.go b/errors.go index 9eb155f..d134b35 100644 --- a/errors.go +++ b/errors.go @@ -2,8 +2,6 @@ package fmesh import ( "errors" - "fmt" - "github.com/hovsep/fmesh/cycle" ) type ErrorHandlingStrategy int @@ -19,7 +17,3 @@ var ( ErrHitAPanic = errors.New("f-mesh hit a panic and will be stopped") ErrUnsupportedErrorHandlingStrategy = errors.New("unsupported error handling strategy") ) - -func newFMeshStopError(err error, cycleResult *cycle.Result) error { - return fmt.Errorf("%w (cycle #%d activation results: %v)", err, cycleResult.CycleNumber(), cycleResult.ActivationResults()) -} diff --git a/fmesh.go b/fmesh.go index 39272f7..d3c05b6 100644 --- a/fmesh.go +++ b/fmesh.go @@ -72,7 +72,7 @@ func (fm *FMesh) runCycle() *cycle.Result { case aRes := <-activationResultsChan: //@TODO :check for closed channel cycleResult.Lock() - cycleResult = cycleResult.WithActivationResult(aRes) + cycleResult = cycleResult.WithActivationResults(aRes) cycleResult.Unlock() case <-doneChan: return @@ -104,15 +104,17 @@ func (fm *FMesh) drainComponents() { // Run starts the computation until there is no component which activates (mesh has no unprocessed inputs) func (fm *FMesh) Run() (cycle.Results, error) { allCycles := cycle.NewResults() + cycleNumber := uint(0) for { - cycleResult := fm.runCycle() + cycleNumber++ + cycleResult := fm.runCycle().SetCycleNumber(cycleNumber) + allCycles = allCycles.Add(cycleResult) mustStop, err := fm.mustStop(cycleResult) if mustStop { return allCycles, err } - allCycles.Add(cycleResult) fm.drainComponents() } } @@ -126,14 +128,12 @@ func (fm *FMesh) mustStop(cycleResult *cycle.Result) (bool, error) { //Check if mesh must stop because of configured error handling strategy switch fm.errorHandlingStrategy { case StopOnFirstError: - return cycleResult.HasErrors(), newFMeshStopError(ErrHitAnError, cycleResult) + return cycleResult.HasErrors(), ErrHitAnError case StopOnFirstPanic: - return cycleResult.HasPanics(), newFMeshStopError(ErrHitAPanic, cycleResult) + return cycleResult.HasPanics(), ErrHitAPanic case IgnoreAll: return false, nil default: - //@TODO: maybe better to return error - return true, ErrUnsupportedErrorHandlingStrategy } } diff --git a/fmesh_test.go b/fmesh_test.go index 3564753..cc979e8 100644 --- a/fmesh_test.go +++ b/fmesh_test.go @@ -1,6 +1,7 @@ package fmesh import ( + "errors" "github.com/hovsep/fmesh/component" "github.com/hovsep/fmesh/cycle" "github.com/hovsep/fmesh/port" @@ -276,35 +277,368 @@ func TestFMesh_Description(t *testing.T) { } func TestFMesh_Run(t *testing.T) { - type fields struct { - name string - description string - components component.Components - errorHandlingStrategy ErrorHandlingStrategy - } tests := []struct { name string - fields fields - want []*cycle.Result + fm *FMesh + initFM func(fm *FMesh) + want cycle.Results wantErr bool }{ - // TODO: Add test cases. + { + name: "empty mesh stops after first cycle", + fm: New("fm"), + want: cycle.NewResults().Add(cycle.NewResult().SetCycleNumber(1)), + wantErr: false, + }, + { + name: "unsupported error handling strategy", + fm: New("fm").WithErrorHandlingStrategy(100). + WithComponents( + component.NewComponent("c1"). + WithDescription("This component simply puts a constant on o1"). + WithInputs("i1"). + WithOutputs("o1"). + WithActivationFunc(func(inputs port.Ports, outputs port.Ports) error { + outputs.ByName("o1").PutSignal(signal.New(77)) + return nil + }), + ), + initFM: func(fm *FMesh) { + //Fire the mesh + fm.Components().ByName("c1").Inputs().ByName("i1").PutSignal(signal.New("start c1")) + }, + want: cycle.NewResults().Add(cycle.NewResult(). + SetCycleNumber(1). + WithActivationResults(component.NewActivationResult("c1"). + SetActivated(true). + WithActivationCode(component.ActivationCodeOK)), + ), + wantErr: true, + }, + { + name: "stop on first error on first cycle", + fm: New("fm"). + WithErrorHandlingStrategy(StopOnFirstError). + WithComponents( + component.NewComponent("c1"). + WithDescription("This component just returns an unexpected error"). + WithInputs("i1"). + WithActivationFunc(func(inputs port.Ports, outputs port.Ports) error { + return errors.New("boom") + })), + initFM: func(fm *FMesh) { + fm.Components().ByName("c1").Inputs().ByName("i1").PutSignal(signal.New("start")) + }, + want: cycle.NewResults().Add( + cycle.NewResult(). + SetCycleNumber(1). + WithActivationResults( + component.NewActivationResult("c1"). + SetActivated(true). + WithActivationCode(component.ActivationCodeReturnedError). + WithError(errors.New("component returned an error: boom")), + ), + ), + wantErr: true, + }, + { + name: "stop on first panic on cycle 3", + fm: New("fm"). + WithErrorHandlingStrategy(StopOnFirstPanic). + WithComponents( + component.NewComponent("c1"). + WithDescription("This component just sends a number to c2"). + WithInputs("i1"). + WithOutputs("o1"). + WithActivationFunc(func(inputs port.Ports, outputs port.Ports) error { + outputs.ByName("o1").PutSignal(signal.New(10)) + return nil + }), + component.NewComponent("c2"). + WithDescription("This component receives a number from c1 and passes it to c4"). + WithInputs("i1"). + WithOutputs("o1"). + WithActivationFunc(func(inputs port.Ports, outputs port.Ports) error { + outputs.ByName("o1").PutSignal(inputs.ByName("i1").Signal()) + return nil + }), + component.NewComponent("c3"). + WithDescription("This component returns an error, but the mesh is configured to ignore errors"). + WithInputs("i1"). + WithOutputs("o1"). + WithActivationFunc(func(inputs port.Ports, outputs port.Ports) error { + return errors.New("boom") + }), + component.NewComponent("c4"). + WithDescription("This component receives a number from c2 and panics"). + WithInputs("i1"). + WithOutputs("o1"). + WithActivationFunc(func(inputs port.Ports, outputs port.Ports) error { + panic("no way") + return nil + }), + ), + initFM: func(fm *FMesh) { + c1, c2, c3, c4 := fm.Components().ByName("c1"), fm.Components().ByName("c2"), fm.Components().ByName("c3"), fm.Components().ByName("c4") + //Piping + c1.Outputs().ByName("o1").PipeTo(c2.Inputs().ByName("i1")) + c2.Outputs().ByName("o1").PipeTo(c4.Inputs().ByName("i1")) + + //Input data + c1.Inputs().ByName("i1").PutSignal(signal.New("start c1")) + c3.Inputs().ByName("i1").PutSignal(signal.New("start c3")) + }, + want: cycle.NewResults().Add( + cycle.NewResult(). + SetCycleNumber(1). + WithActivationResults( + component.NewActivationResult("c1"). + SetActivated(true). + WithActivationCode(component.ActivationCodeOK), + component.NewActivationResult("c2"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + component.NewActivationResult("c3"). + SetActivated(true). + WithActivationCode(component.ActivationCodeReturnedError). + WithError(errors.New("component returned an error: boom")), + component.NewActivationResult("c4"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + ), + cycle.NewResult(). + SetCycleNumber(2). + WithActivationResults( + component.NewActivationResult("c1"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + component.NewActivationResult("c2"). + SetActivated(true). + WithActivationCode(component.ActivationCodeOK), + component.NewActivationResult("c3"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + component.NewActivationResult("c4"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + ), + cycle.NewResult(). + SetCycleNumber(3). + WithActivationResults( + component.NewActivationResult("c1"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + component.NewActivationResult("c2"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + component.NewActivationResult("c3"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + component.NewActivationResult("c4"). + SetActivated(true). + WithActivationCode(component.ActivationCodePanicked). + WithError(errors.New("panicked with: no way")), + ), + ), + wantErr: true, + }, + { + name: "all errors and panics are ignored", + fm: New("fm"). + WithErrorHandlingStrategy(IgnoreAll). + WithComponents( + component.NewComponent("c1"). + WithDescription("This component just sends a number to c2"). + WithInputs("i1"). + WithOutputs("o1"). + WithActivationFunc(func(inputs port.Ports, outputs port.Ports) error { + outputs.ByName("o1").PutSignal(signal.New(10)) + return nil + }), + component.NewComponent("c2"). + WithDescription("This component receives a number from c1 and passes it to c4"). + WithInputs("i1"). + WithOutputs("o1"). + WithActivationFunc(func(inputs port.Ports, outputs port.Ports) error { + outputs.ByName("o1").PutSignal(inputs.ByName("i1").Signal()) + return nil + }), + component.NewComponent("c3"). + WithDescription("This component returns an error, but the mesh is configured to ignore errors"). + WithInputs("i1"). + WithOutputs("o1"). + WithActivationFunc(func(inputs port.Ports, outputs port.Ports) error { + return errors.New("boom") + }), + component.NewComponent("c4"). + WithDescription("This component receives a number from c2 and panics, but the mesh is configured to ignore even panics"). + WithInputs("i1"). + WithOutputs("o1"). + WithActivationFunc(func(inputs port.Ports, outputs port.Ports) error { + outputs.ByName("o1").PutSignal(inputs.ByName("i1").Signal()) + + // Even component panicked, it managed to set some data on output "o1" + // so that data will be available in next cycle + panic("no way") + return nil + }), + component.NewComponent("c5"). + WithDescription("This component receives a number from c4"). + WithInputs("i1"). + WithOutputs("o1"). + WithActivationFunc(func(inputs port.Ports, outputs port.Ports) error { + outputs.ByName("o1").PutSignal(inputs.ByName("i1").Signal()) + return nil + }), + ), + initFM: func(fm *FMesh) { + c1, c2, c3, c4, c5 := fm.Components().ByName("c1"), fm.Components().ByName("c2"), fm.Components().ByName("c3"), fm.Components().ByName("c4"), fm.Components().ByName("c5") + //Piping + c1.Outputs().ByName("o1").PipeTo(c2.Inputs().ByName("i1")) + c2.Outputs().ByName("o1").PipeTo(c4.Inputs().ByName("i1")) + c4.Outputs().ByName("o1").PipeTo(c5.Inputs().ByName("i1")) + + //Input data + c1.Inputs().ByName("i1").PutSignal(signal.New("start c1")) + c3.Inputs().ByName("i1").PutSignal(signal.New("start c3")) + }, + want: cycle.NewResults().Add( + //c1 and c3 activated, c3 finishes with error + cycle.NewResult(). + SetCycleNumber(1). + WithActivationResults( + component.NewActivationResult("c1"). + SetActivated(true). + WithActivationCode(component.ActivationCodeOK), + component.NewActivationResult("c2"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + component.NewActivationResult("c3"). + SetActivated(true). + WithActivationCode(component.ActivationCodeReturnedError). + WithError(errors.New("component returned an error: boom")), + component.NewActivationResult("c4"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + component.NewActivationResult("c5"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + ), + // Only c2 is activated + cycle.NewResult(). + SetCycleNumber(2). + WithActivationResults( + component.NewActivationResult("c1"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + component.NewActivationResult("c2"). + SetActivated(true). + WithActivationCode(component.ActivationCodeOK), + component.NewActivationResult("c3"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + component.NewActivationResult("c4"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + component.NewActivationResult("c5"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + ), + //Only c4 is activated and panicked + cycle.NewResult(). + SetCycleNumber(3). + WithActivationResults( + component.NewActivationResult("c1"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + component.NewActivationResult("c2"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + component.NewActivationResult("c3"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + component.NewActivationResult("c4"). + SetActivated(true). + WithActivationCode(component.ActivationCodePanicked). + WithError(errors.New("panicked with: no way")), + component.NewActivationResult("c5"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + ), + //Only c5 is activated (after c4 panicked in previous cycle) + cycle.NewResult(). + SetCycleNumber(4). + WithActivationResults( + component.NewActivationResult("c1"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + component.NewActivationResult("c2"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + component.NewActivationResult("c3"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + component.NewActivationResult("c4"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + component.NewActivationResult("c5"). + SetActivated(true). + WithActivationCode(component.ActivationCodeOK), + ), + //Last (control) cycle, no component activated, so f-mesh stops naturally + cycle.NewResult(). + SetCycleNumber(5). + WithActivationResults( + component.NewActivationResult("c1"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + component.NewActivationResult("c2"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + component.NewActivationResult("c3"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + component.NewActivationResult("c4"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + component.NewActivationResult("c5"). + SetActivated(false). + WithActivationCode(component.ActivationCodeNoInput), + ), + ), + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - fm := &FMesh{ - name: tt.fields.name, - description: tt.fields.description, - components: tt.fields.components, - errorHandlingStrategy: tt.fields.errorHandlingStrategy, + if tt.initFM != nil { + tt.initFM(tt.fm) } - got, err := fm.Run() - if (err != nil) != tt.wantErr { - t.Errorf("Run() error = %v, wantErr %v", err, tt.wantErr) - return + got, err := tt.fm.Run() + assert.Equal(t, len(tt.want), len(got)) + if tt.wantErr { + assert.NotNil(t, err) + } else { + assert.Nil(t, err) } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("Run() got = %v, want %v", got, tt.want) + + //Compare cycle results one by one + for i := 0; i < len(got); i++ { + assert.Equal(t, tt.want[i].CycleNumber(), got[i].CycleNumber()) + assert.Equal(t, len(tt.want[i].ActivationResults()), len(got[i].ActivationResults()), "ActivationResults len mismatch") + + //Compare activation results + for componentName, gotActivationResult := range got[i].ActivationResults() { + assert.Equal(t, tt.want[i].ActivationResults()[componentName].Activated(), gotActivationResult.Activated()) + assert.Equal(t, tt.want[i].ActivationResults()[componentName].ComponentName(), gotActivationResult.ComponentName()) + assert.Equal(t, tt.want[i].ActivationResults()[componentName].Code(), gotActivationResult.Code()) + + if tt.want[i].ActivationResults()[componentName].HasError() { + assert.EqualError(t, tt.want[i].ActivationResults()[componentName].Error(), gotActivationResult.Error().Error()) + } else { + assert.False(t, gotActivationResult.HasError()) + } + } } }) } diff --git a/port/port.go b/port/port.go index 061760e..e2799c8 100644 --- a/port/port.go +++ b/port/port.go @@ -60,15 +60,15 @@ func (p *Port) HasSignal() bool { // Adds pipe reference to the port, so all pipes of the port are easily accessible func (p *Port) addPipeRef(pipe *Pipe) { - if pipe.From == nil || pipe.To == nil { - return - } p.pipes = append(p.pipes, pipe) } // PipeTo creates one or multiple pipes to other port(s) func (p *Port) PipeTo(toPorts ...*Port) { for _, toPort := range toPorts { + if toPort == nil { + continue + } newPipe := NewPipe(p, toPort) p.addPipeRef(newPipe) toPort.addPipeRef(newPipe)