From 641a682941e4e6cdf5ee719d979152141cd44e7b Mon Sep 17 00:00:00 2001 From: kschneider84 <77678452+kschneider84@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:07:41 +0200 Subject: [PATCH 1/7] fix: improve ISO week calculation in TestEvalGetTsForNextISOWeek --- pkg/study/studyengine/expressions_test.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pkg/study/studyengine/expressions_test.go b/pkg/study/studyengine/expressions_test.go index f209ad8..bf74157 100644 --- a/pkg/study/studyengine/expressions_test.go +++ b/pkg/study/studyengine/expressions_test.go @@ -3154,8 +3154,16 @@ func TestEvalGetTsForNextISOWeek(t *testing.T) { ts := ret.(float64) tsD := time.Unix(int64(ts), 0) refTs = refTs.AddDate(1, 0, 0) - // beginning of the year + // Find the first date in ISO week 1. January 1 can belong to + // the previous ISO year (for example, January 1, 2027 is 2026-W53). refTs = time.Date(refTs.Year(), 1, 1, 0, 0, 0, 0, time.Local) + for { + _, week := refTs.ISOWeek() + if week == 1 { + break + } + refTs = refTs.AddDate(0, 0, 1) + } y_i, w_i := refTs.ISOWeek() y, w := tsD.ISOWeek() if y != y_i || w != w_i { From d3ef5c795b4971f1af1efef553722e47cf22bc2f Mon Sep 17 00:00:00 2001 From: kschneider84 <77678452+kschneider84@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:44:39 +0200 Subject: [PATCH 2/7] add time difference and TsForStartOfWeek to expressions --- pkg/study/studyengine/expressions.go | 62 ++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/pkg/study/studyengine/expressions.go b/pkg/study/studyengine/expressions.go index add6ecf..78af72a 100644 --- a/pkg/study/studyengine/expressions.go +++ b/pkg/study/studyengine/expressions.go @@ -161,12 +161,16 @@ func ExpressionEval(expression studyTypes.Expression, evalCtx EvalContext) (val // Other case "timestampWithOffset": val, err = evalCtx.timestampWithOffset(expression) + case "timestampDiff": + val, err = evalCtx.timestampDiff(expression) case "getTsForNextStartOfMonth": val, err = evalCtx.getTsForNextStartOfMonth(expression) case "getISOWeekForTs": val, err = evalCtx.getISOWeekForTs(expression) case "getTsForNextISOWeek": val, err = evalCtx.getTsForNextISOWeek(expression) + case "getTsForStartOfISOWeek": + val, err = evalCtx.getTsForStartOfISOWeek(expression) case "dateToStr": val, err = evalCtx.dateToStr(expression) case "parseValueAsNum": @@ -1703,6 +1707,33 @@ func (ctx EvalContext) timestampWithOffset(exp studyTypes.Expression) (t float64 return } +func (ctx EvalContext) timestampDiff(exp studyTypes.Expression) (t float64, err error) { + if len(exp.Data) != 2 { + return t, errors.New("should have exactly two arguments") + } + + arg1, err1 := ctx.ExpressionArgResolver(exp.Data[0]) + if err1 != nil { + return t, err1 + } + if reflect.TypeOf(arg1).Kind() != reflect.Float64 { + return t, errors.New("argument 1 should be resolved as type number (float64)") + } + laterTimestamp := arg1.(float64) + + arg2, err2 := ctx.ExpressionArgResolver(exp.Data[1]) + if err2 != nil { + return t, err2 + } + if reflect.TypeOf(arg2).Kind() != reflect.Float64 { + return t, errors.New("argument 2 should be resolved as type number (float64)") + } + earlierTimestamp := arg2.(float64) + + t = laterTimestamp - earlierTimestamp + return +} + func (ctx EvalContext) getTsForNextStartOfMonth(exp studyTypes.Expression) (t float64, err error) { if len(exp.Data) != 1 && len(exp.Data) != 2 { return t, errors.New("should have one or two arguments") @@ -1837,6 +1868,37 @@ func (ctx EvalContext) getTsForNextISOWeek(exp studyTypes.Expression) (t float64 return } +func (ctx EvalContext) getTsForStartOfISOWeek(exp studyTypes.Expression) (t float64, err error) { + if len(exp.Data) != 0 && len(exp.Data) != 1 { + return t, errors.New("should have zero or one argument") + } + + referenceTime := Now() + if len(exp.Data) == 1 { + arg1, err1 := ctx.ExpressionArgResolver(exp.Data[0]) + if err1 != nil { + return t, err1 + } + if reflect.TypeOf(arg1).Kind() != reflect.Float64 { + return t, errors.New("argument 1 should be resolved as type number (float64)") + } + + referenceTime = time.Unix(int64(arg1.(float64)), 0) + } + + weekday := int(referenceTime.Weekday()) + if weekday == 0 { + // time.Sunday is 0, but for ISO weeks Sunday is the 7th day + weekday = 7 + } + + startOfWeek := referenceTime.AddDate(0, 0, -weekday+1) + startOfWeek = time.Date(startOfWeek.Year(), startOfWeek.Month(), startOfWeek.Day(), 0, 0, 0, 0, startOfWeek.Location()) + + t = float64(startOfWeek.Unix()) + return +} + func (ctx EvalContext) getISOWeekForTs(exp studyTypes.Expression) (t float64, err error) { if len(exp.Data) != 1 { return t, errors.New("should have one argument") From 4c019f51c02ea0543d60d2beb958290ebfc3ed12 Mon Sep 17 00:00:00 2001 From: kschneider84 <77678452+kschneider84@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:57:30 +0200 Subject: [PATCH 3/7] add tests to validate new expressions --- pkg/study/studyengine/expressions_test.go | 317 ++++++++++++++++++++++ 1 file changed, 317 insertions(+) diff --git a/pkg/study/studyengine/expressions_test.go b/pkg/study/studyengine/expressions_test.go index bf74157..21a4e91 100644 --- a/pkg/study/studyengine/expressions_test.go +++ b/pkg/study/studyengine/expressions_test.go @@ -3210,6 +3210,323 @@ func TestEvalGetTsForNextISOWeek(t *testing.T) { }) } +func TestEvalGetTsForStartOfISOWeek(t *testing.T) { + t.Run("wrong reference type", func(t *testing.T) { + exp := studyTypes.Expression{Name: "getTsForStartOfISOWeek", Data: []studyTypes.ExpressionArg{ + {DType: "str", Str: "test"}, + }} + EvalContext := EvalContext{} + _, err := ExpressionEval(exp, EvalContext) + if err == nil { + t.Error("should return type error") + return + } + }) + + t.Run("without reference", func(t *testing.T) { + exp := studyTypes.Expression{Name: "getTsForStartOfISOWeek", Data: []studyTypes.ExpressionArg{}} + EvalContext := EvalContext{} + ret, err := ExpressionEval(exp, EvalContext) + if err != nil { + t.Errorf("unexpected error: %s", err.Error()) + return + } + ts := ret.(float64) + tsD := time.Unix(int64(ts), 0) + + refTs := Now() + y_i, w_i := refTs.ISOWeek() + y, w := tsD.ISOWeek() + if y != y_i || w != w_i { + t.Errorf("unexpected value: %d-%d, expected %d-%d", y, w, y_i, w_i) + } + + if tsD.Weekday() != time.Monday { + t.Errorf("unexpected weekday: %s, expected Monday", tsD.Weekday()) + } + if tsD.Hour() != 0 || tsD.Minute() != 0 || tsD.Second() != 0 { + t.Errorf("unexpected time of day: %02d:%02d:%02d, expected 00:00:00", tsD.Hour(), tsD.Minute(), tsD.Second()) + } + }) + + t.Run("with absolute reference on a wednesday", func(t *testing.T) { + refTs := time.Date(2023, 9, 13, 15, 30, 0, 0, time.Local) // Wednesday + exp := studyTypes.Expression{Name: "getTsForStartOfISOWeek", Data: []studyTypes.ExpressionArg{ + {DType: "num", Num: float64(refTs.Unix())}, + }} + EvalContext := EvalContext{} + ret, err := ExpressionEval(exp, EvalContext) + if err != nil { + t.Errorf("unexpected error: %s", err.Error()) + return + } + ts := ret.(float64) + tsD := time.Unix(int64(ts), 0) + + y_i, w_i := refTs.ISOWeek() + y, w := tsD.ISOWeek() + if y != y_i || w != w_i { + t.Errorf("unexpected value: %d-%d, expected %d-%d", y, w, y_i, w_i) + } + + expectedDate := time.Date(2023, 9, 11, 0, 0, 0, 0, time.Local) // Monday of that week + if !tsD.Equal(expectedDate) { + t.Errorf("unexpected date: %s, expected %s", tsD, expectedDate) + } + }) + + t.Run("with absolute reference on a monday", func(t *testing.T) { + refTs := time.Date(2023, 9, 11, 8, 0, 0, 0, time.Local) // already a Monday + exp := studyTypes.Expression{Name: "getTsForStartOfISOWeek", Data: []studyTypes.ExpressionArg{ + {DType: "num", Num: float64(refTs.Unix())}, + }} + EvalContext := EvalContext{} + ret, err := ExpressionEval(exp, EvalContext) + if err != nil { + t.Errorf("unexpected error: %s", err.Error()) + return + } + ts := ret.(float64) + tsD := time.Unix(int64(ts), 0) + + expectedDate := time.Date(2023, 9, 11, 0, 0, 0, 0, time.Local) + if !tsD.Equal(expectedDate) { + t.Errorf("unexpected date: %s, expected %s", tsD, expectedDate) + } + }) + + t.Run("with absolute reference on a sunday", func(t *testing.T) { + refTs := time.Date(2023, 9, 17, 23, 59, 59, 0, time.Local) // Sunday + exp := studyTypes.Expression{Name: "getTsForStartOfISOWeek", Data: []studyTypes.ExpressionArg{ + {DType: "num", Num: float64(refTs.Unix())}, + }} + EvalContext := EvalContext{} + ret, err := ExpressionEval(exp, EvalContext) + if err != nil { + t.Errorf("unexpected error: %s", err.Error()) + return + } + ts := ret.(float64) + tsD := time.Unix(int64(ts), 0) + + expectedDate := time.Date(2023, 9, 11, 0, 0, 0, 0, time.Local) // Monday of the same ISO week + if !tsD.Equal(expectedDate) { + t.Errorf("unexpected date: %s, expected %s", tsD, expectedDate) + } + }) + + t.Run("with relative reference", func(t *testing.T) { + exp := studyTypes.Expression{Name: "getTsForStartOfISOWeek", Data: []studyTypes.ExpressionArg{ + {DType: "exp", Exp: &studyTypes.Expression{ + Name: "timestampWithOffset", + Data: []studyTypes.ExpressionArg{ + {DType: "num", Num: 0}, + }, + }}, + }} + EvalContext := EvalContext{} + ret, err := ExpressionEval(exp, EvalContext) + if err != nil { + t.Errorf("unexpected error: %s", err.Error()) + return + } + ts := ret.(float64) + tsD := time.Unix(int64(ts), 0) + + refTs := Now() + y_i, w_i := refTs.ISOWeek() + y, w := tsD.ISOWeek() + if y != y_i || w != w_i { + t.Errorf("unexpected value: %d-%d, expected %d-%d", y, w, y_i, w_i) + } + }) +} + +func TestEvalTimestampDiff(t *testing.T) { + t.Run("wrong number of arguments", func(t *testing.T) { + exp := studyTypes.Expression{Name: "timestampDiff", Data: []studyTypes.ExpressionArg{ + {DType: "num", Num: 100}, + }} + EvalContext := EvalContext{} + _, err := ExpressionEval(exp, EvalContext) + if err == nil { + t.Error("should return error for wrong number of arguments") + return + } + }) + + t.Run("wrong type for argument 1", func(t *testing.T) { + exp := studyTypes.Expression{Name: "timestampDiff", Data: []studyTypes.ExpressionArg{ + {DType: "str", Str: "test"}, + {DType: "num", Num: 100}, + }} + EvalContext := EvalContext{} + _, err := ExpressionEval(exp, EvalContext) + if err == nil { + t.Error("should return type error") + return + } + }) + + t.Run("wrong type for argument 2", func(t *testing.T) { + exp := studyTypes.Expression{Name: "timestampDiff", Data: []studyTypes.ExpressionArg{ + {DType: "num", Num: 100}, + {DType: "str", Str: "test"}, + }} + EvalContext := EvalContext{} + _, err := ExpressionEval(exp, EvalContext) + if err == nil { + t.Error("should return type error") + return + } + }) + + t.Run("positive difference", func(t *testing.T) { + exp := studyTypes.Expression{Name: "timestampDiff", Data: []studyTypes.ExpressionArg{ + {DType: "num", Num: 200}, + {DType: "num", Num: 150}, + }} + EvalContext := EvalContext{} + ret, err := ExpressionEval(exp, EvalContext) + if err != nil { + t.Errorf("unexpected error: %s", err.Error()) + return + } + diff := ret.(float64) + if diff != 50 { + t.Errorf("unexpected value: %f, expected %f", diff, float64(50)) + } + }) + + t.Run("negative difference", func(t *testing.T) { + exp := studyTypes.Expression{Name: "timestampDiff", Data: []studyTypes.ExpressionArg{ + {DType: "num", Num: 150}, + {DType: "num", Num: 200}, + }} + EvalContext := EvalContext{} + ret, err := ExpressionEval(exp, EvalContext) + if err != nil { + t.Errorf("unexpected error: %s", err.Error()) + return + } + diff := ret.(float64) + if diff != -50 { + t.Errorf("unexpected value: %f, expected %f", diff, float64(-50)) + } + }) + + t.Run("equal timestamps", func(t *testing.T) { + exp := studyTypes.Expression{Name: "timestampDiff", Data: []studyTypes.ExpressionArg{ + {DType: "num", Num: 100}, + {DType: "num", Num: 100}, + }} + EvalContext := EvalContext{} + ret, err := ExpressionEval(exp, EvalContext) + if err != nil { + t.Errorf("unexpected error: %s", err.Error()) + return + } + diff := ret.(float64) + if diff != 0 { + t.Errorf("unexpected value: %f, expected %f", diff, float64(0)) + } + }) + + t.Run("with expressions as arguments", func(t *testing.T) { + exp := studyTypes.Expression{Name: "timestampDiff", Data: []studyTypes.ExpressionArg{ + {DType: "exp", Exp: &studyTypes.Expression{ + Name: "timestampWithOffset", + Data: []studyTypes.ExpressionArg{ + {DType: "num", Num: 0}, + {DType: "num", Num: 200}, + }, + }}, + {DType: "exp", Exp: &studyTypes.Expression{ + Name: "timestampWithOffset", + Data: []studyTypes.ExpressionArg{ + {DType: "num", Num: 0}, + {DType: "num", Num: 150}, + }, + }}, + }} + EvalContext := EvalContext{} + ret, err := ExpressionEval(exp, EvalContext) + if err != nil { + t.Errorf("unexpected error: %s", err.Error()) + return + } + diff := ret.(float64) + if diff != 50 { + t.Errorf("unexpected value: %f, expected %f", diff, float64(50)) + } + }) + + // Tests the composed condition: + // + // gte( + // timestampDiff(currentTs, getTsForStartOfISOWeek(refTs)), + // 259200 + // ) + // + // which evaluates to true once at least 72 hours (259200 seconds) have + // passed since the start (Monday 00:00:00) of the ISO week containing refTs. + // Both timestamps are fixed values. + t.Run("used in gte condition for at least 72 hours since week start", func(t *testing.T) { + // Wednesday, 2023-09-13 - any day within the reference ISO week. + refTs := time.Date(2023, 9, 13, 12, 0, 0, 0, time.Local).Unix() + // Start of that ISO week: Monday, 2023-09-11, 00:00:00. + startOfWeek := time.Date(2023, 9, 11, 0, 0, 0, 0, time.Local).Unix() + + buildExp := func(currentTs int64) studyTypes.Expression { + return studyTypes.Expression{ + Name: "gte", + Data: []studyTypes.ExpressionArg{ + {DType: "exp", Exp: &studyTypes.Expression{ + Name: "timestampDiff", + Data: []studyTypes.ExpressionArg{ + {DType: "num", Num: float64(currentTs)}, + {DType: "exp", Exp: &studyTypes.Expression{ + Name: "getTsForStartOfISOWeek", + Data: []studyTypes.ExpressionArg{ + {DType: "num", Num: float64(refTs)}, + }, + }}, + }, + }}, + {DType: "num", Num: 259200}, + }, + } + } + + run := func(t *testing.T, currentTs int64) bool { + EvalContext := EvalContext{} + ret, err := ExpressionEval(buildExp(currentTs), EvalContext) + if err != nil { + t.Fatalf("unexpected error: %s", err.Error()) + } + val, ok := ret.(bool) + if !ok { + t.Fatalf("unexpected return type: %T", ret) + } + return val + } + + t.Run("less than 72 hours after week start", func(t *testing.T) { + currentTs := startOfWeek + int64((71*time.Hour + 59*time.Minute).Seconds()) + if run(t, currentTs) { + t.Error("expected condition to be false before 72 hours have passed") + } + }) + + t.Run("well more than 72 hours after week start", func(t *testing.T) { + currentTs := startOfWeek + int64((100 * time.Hour).Seconds()) + if !run(t, currentTs) { + t.Error("expected condition to be true when more than 72 hours have passed") + } + }) + }) +} + func TestEvalHasMessageTypeAssigned(t *testing.T) { t.Run("participant has no messages", func(t *testing.T) { exp := studyTypes.Expression{Name: "hasMessageTypeAssigned", Data: []studyTypes.ExpressionArg{ From f843126a8a7e13c2b236a7533c862042a38598dc Mon Sep 17 00:00:00 2001 From: kschneider84 <77678452+kschneider84@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:12:45 +0200 Subject: [PATCH 4/7] Fix potential nil-pointer panic in expression type checks --- pkg/study/studyengine/expressions.go | 69 ++++++++++++++++------------ 1 file changed, 39 insertions(+), 30 deletions(-) diff --git a/pkg/study/studyengine/expressions.go b/pkg/study/studyengine/expressions.go index 78af72a..873c2b8 100644 --- a/pkg/study/studyengine/expressions.go +++ b/pkg/study/studyengine/expressions.go @@ -5,7 +5,6 @@ import ( "fmt" "log/slog" "math/rand" - "reflect" "strconv" "strings" "time" @@ -1652,7 +1651,7 @@ func (ctx EvalContext) sum(exp studyTypes.Expression) (t float64, err error) { case float64: t = t + v default: - slog.Error("unexpected type during expression eval", slog.Int("index", idx), slog.String("expression", exp.Name), slog.String("type", reflect.TypeOf(arg).String())) + slog.Error("unexpected type during expression eval", slog.Int("index", idx), slog.String("expression", exp.Name), slog.String("type", fmt.Sprintf("%T", arg))) } } @@ -1668,10 +1667,10 @@ func (ctx EvalContext) neg(exp studyTypes.Expression) (val float64, err error) { if err != nil { return val, err } - if reflect.TypeOf(arg).Kind() != reflect.Float64 { + v, ok := arg.(float64) + if !ok { return val, errors.New("argument 1 should be resolved as type number (float64)") } - v := arg.(float64) val = -1 * v return } @@ -1685,10 +1684,11 @@ func (ctx EvalContext) timestampWithOffset(exp studyTypes.Expression) (t float64 if err1 != nil { return t, err1 } - if reflect.TypeOf(arg1).Kind() != reflect.Float64 { + arg1Float, ok := arg1.(float64) + if !ok { return t, errors.New("argument 1 should be resolved as type number (float64)") } - delta := int64(arg1.(float64)) + delta := int64(arg1Float) referenceTime := Now().Unix() if len(exp.Data) == 2 { @@ -1696,11 +1696,12 @@ func (ctx EvalContext) timestampWithOffset(exp studyTypes.Expression) (t float64 if err2 != nil { return t, err2 } - if reflect.TypeOf(arg2).Kind() != reflect.Float64 { + arg2Float, ok := arg2.(float64) + if !ok { return t, errors.New("argument 2 should be resolved as type number (float64)") } - referenceTime = int64(arg2.(float64)) + referenceTime = int64(arg2Float) } t = float64(referenceTime + delta) @@ -1716,19 +1717,19 @@ func (ctx EvalContext) timestampDiff(exp studyTypes.Expression) (t float64, err if err1 != nil { return t, err1 } - if reflect.TypeOf(arg1).Kind() != reflect.Float64 { + laterTimestamp, ok := arg1.(float64) + if !ok { return t, errors.New("argument 1 should be resolved as type number (float64)") } - laterTimestamp := arg1.(float64) arg2, err2 := ctx.ExpressionArgResolver(exp.Data[1]) if err2 != nil { return t, err2 } - if reflect.TypeOf(arg2).Kind() != reflect.Float64 { + earlierTimestamp, ok := arg2.(float64) + if !ok { return t, errors.New("argument 2 should be resolved as type number (float64)") } - earlierTimestamp := arg2.(float64) t = laterTimestamp - earlierTimestamp return @@ -1793,11 +1794,12 @@ func (ctx EvalContext) getTsForNextStartOfMonth(exp studyTypes.Expression) (t fl if err2 != nil { return t, err2 } - if reflect.TypeOf(arg2).Kind() != reflect.Float64 { + arg2Float, ok := arg2.(float64) + if !ok { return t, errors.New("argument 2 should be resolved as type number (float64)") } - referenceTime = time.Unix(int64(arg2.(float64)), 0) + referenceTime = time.Unix(int64(arg2Float), 0) } // Get the first day of the next occurrence of the specified month @@ -1832,11 +1834,12 @@ func (ctx EvalContext) getTsForNextISOWeek(exp studyTypes.Expression) (t float64 if err1 != nil { return t, err1 } - if reflect.TypeOf(arg1).Kind() != reflect.Float64 { + arg1Float, ok := arg1.(float64) + if !ok { return t, errors.New("argument 1 should be resolved as type number (float64)") } - ISOWeek := int64(arg1.(float64)) + ISOWeek := int64(arg1Float) if ISOWeek < 1 || ISOWeek > 53 { return t, errors.New("argument 1 should be between 1 and 53") @@ -1848,11 +1851,12 @@ func (ctx EvalContext) getTsForNextISOWeek(exp studyTypes.Expression) (t float64 if err2 != nil { return t, err2 } - if reflect.TypeOf(arg2).Kind() != reflect.Float64 { + arg2Float, ok := arg2.(float64) + if !ok { return t, errors.New("argument 2 should be resolved as type number (float64)") } - referenceTime = time.Unix(int64(arg2.(float64)), 0) + referenceTime = time.Unix(int64(arg2Float), 0) } for { @@ -1879,11 +1883,12 @@ func (ctx EvalContext) getTsForStartOfISOWeek(exp studyTypes.Expression) (t floa if err1 != nil { return t, err1 } - if reflect.TypeOf(arg1).Kind() != reflect.Float64 { + arg1Float, ok := arg1.(float64) + if !ok { return t, errors.New("argument 1 should be resolved as type number (float64)") } - referenceTime = time.Unix(int64(arg1.(float64)), 0) + referenceTime = time.Unix(int64(arg1Float), 0) } weekday := int(referenceTime.Weekday()) @@ -1908,11 +1913,12 @@ func (ctx EvalContext) getISOWeekForTs(exp studyTypes.Expression) (t float64, er if err1 != nil { return t, err1 } - if reflect.TypeOf(arg1).Kind() != reflect.Float64 { + arg1Float, ok := arg1.(float64) + if !ok { return t, errors.New("argument 1 should be resolved as type number (float64)") } - ts := int64(arg1.(float64)) + ts := int64(arg1Float) _, iw := time.Unix(ts, 0).ISOWeek() t = float64(iw) return @@ -1967,15 +1973,16 @@ func (ctx EvalContext) parseValueAsNum(exp studyTypes.Expression) (val float64, return val, err } - if reflect.TypeOf(arg1).Kind() == reflect.Float64 { - return arg1.(float64), nil + if v, ok := arg1.(float64); ok { + return v, nil } - if reflect.TypeOf(arg1).Kind() != reflect.String { + strVal, ok := arg1.(string) + if !ok { return val, errors.New("argument 1 should be resolved as type string") } - val, err = strconv.ParseFloat(arg1.(string), 64) + val, err = strconv.ParseFloat(strVal, 64) return } @@ -1989,19 +1996,21 @@ func (ctx EvalContext) generateRandomNumber(exp studyTypes.Expression) (val floa if err != nil { return val, err } - if reflect.TypeOf(arg1).Kind() != reflect.Float64 { + arg1Float, ok := arg1.(float64) + if !ok { return val, errors.New("argument 1 should be resolved as type number (float64)") } - min := int(arg1.(float64)) + min := int(arg1Float) arg2, err := ctx.ExpressionArgResolver(exp.Data[1]) if err != nil { return val, err } - if reflect.TypeOf(arg2).Kind() != reflect.Float64 { + arg2Float, ok := arg2.(float64) + if !ok { return val, errors.New("argument 2 should be resolved as type number (float64)") } - max := int(arg2.(float64)) + max := int(arg2Float) rand.Seed(time.Now().UnixNano()) randomVal := rand.Intn(max-min+1) + min From d7669c39c6e7c4299cd0ee1b8736c5c2100ab6c9 Mon Sep 17 00:00:00 2001 From: kschneider84 <77678452+kschneider84@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:13:19 +0200 Subject: [PATCH 5/7] Fix Sunday weekday bug and normalize result to midnight --- pkg/study/studyengine/expressions.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pkg/study/studyengine/expressions.go b/pkg/study/studyengine/expressions.go index 873c2b8..61f6115 100644 --- a/pkg/study/studyengine/expressions.go +++ b/pkg/study/studyengine/expressions.go @@ -1867,7 +1867,15 @@ func (ctx EvalContext) getTsForNextISOWeek(exp studyTypes.Expression) (t float64 referenceTime = referenceTime.AddDate(0, 0, 1) } - startOfWeek := referenceTime.AddDate(0, 0, -int(referenceTime.Weekday())+1) + weekday := int(referenceTime.Weekday()) + if weekday == 0 { + // time.Sunday is 0, but for ISO weeks Sunday is the 7th day + weekday = 7 + } + + startOfWeek := referenceTime.AddDate(0, 0, -weekday+1) + startOfWeek = time.Date(startOfWeek.Year(), startOfWeek.Month(), startOfWeek.Day(), 0, 0, 0, 0, startOfWeek.Location()) + t = float64(startOfWeek.Unix()) return } From 2b23c4d5dcf5e39a0354d06e0b8b97243443863c Mon Sep 17 00:00:00 2001 From: kschneider84 <77678452+kschneider84@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:13:38 +0200 Subject: [PATCH 6/7] Add some tests to validate fixes --- pkg/study/studyengine/expressions_test.go | 129 ++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/pkg/study/studyengine/expressions_test.go b/pkg/study/studyengine/expressions_test.go index 21a4e91..1b8b593 100644 --- a/pkg/study/studyengine/expressions_test.go +++ b/pkg/study/studyengine/expressions_test.go @@ -2,6 +2,8 @@ package studyengine import ( "fmt" + "net/http" + "net/http/httptest" "testing" "time" @@ -3208,6 +3210,35 @@ func TestEvalGetTsForNextISOWeek(t *testing.T) { t.Errorf("unexpected value: %d-%d, expected %d-%d", y, w, y_i, w_i) } }) + + t.Run("result is start of week (Monday midnight), including when reference is a Sunday", func(t *testing.T) { + // Reference is a Sunday (2023-09-10). Before the fix, Weekday() == 0 for Sunday + // caused the week-start calculation to jump forward instead of back to Monday. + refTs := time.Date(2023, 9, 10, 15, 30, 0, 0, time.Local) + if refTs.Weekday() != time.Sunday { + t.Fatalf("test setup error: reference date is not a Sunday") + } + + exp := studyTypes.Expression{Name: "getTsForNextISOWeek", Data: []studyTypes.ExpressionArg{ + {DType: "num", Num: 1}, + {DType: "num", Num: float64(refTs.Unix())}, + }} + EvalContext := EvalContext{} + ret, err := ExpressionEval(exp, EvalContext) + if err != nil { + t.Errorf("unexpected error: %s", err.Error()) + return + } + ts := ret.(float64) + tsD := time.Unix(int64(ts), 0) + + if tsD.Weekday() != time.Monday { + t.Errorf("unexpected weekday: %s, expected Monday", tsD.Weekday()) + } + if tsD.Hour() != 0 || tsD.Minute() != 0 || tsD.Second() != 0 { + t.Errorf("unexpected time of day: %02d:%02d:%02d, expected midnight", tsD.Hour(), tsD.Minute(), tsD.Second()) + } + }) } func TestEvalGetTsForStartOfISOWeek(t *testing.T) { @@ -3978,3 +4009,101 @@ func TestNow(t *testing.T) { Now = time.Now // resetting to current time }) } + +// TestEvalNilArgumentHandling ensures that a nil-resolved argument (e.g., from an +// externalEventEval response missing the "value" key) produces a graceful error +// instead of a panic in reflect.TypeOf(arg).Kind()-based type checks. +func TestEvalNilArgumentHandling(t *testing.T) { + // newNilArg builds an ExpressionArg that resolves to nil, by wiring an + // "externalEventEval" call to a test server whose response omits the "value" key. + newNilArg := func(t *testing.T) studyTypes.ExpressionArg { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"unrelated": "field"}`)) + })) + t.Cleanup(server.Close) + + previousStudyEngine := CurrentStudyEngine + CurrentStudyEngine = &StudyEngine{ + externalServices: []ExternalService{ + {Name: "nilService", URL: server.URL}, + }, + } + t.Cleanup(func() { CurrentStudyEngine = previousStudyEngine }) + + return studyTypes.ExpressionArg{ + DType: "exp", + Exp: &studyTypes.Expression{ + Name: "externalEventEval", + Data: []studyTypes.ExpressionArg{ + {DType: "str", Str: "nilService"}, + }, + }, + } + } + + expectGracefulError := func(t *testing.T, exp studyTypes.Expression) { + t.Helper() + _, err := ExpressionEval(exp, EvalContext{}) + if err == nil { + t.Error("expected a graceful error instead of a panic when the argument resolves to nil") + } + } + + t.Run("neg with nil argument", func(t *testing.T) { + expectGracefulError(t, studyTypes.Expression{Name: "neg", Data: []studyTypes.ExpressionArg{ + newNilArg(t), + }}) + }) + + t.Run("timestampWithOffset with nil argument", func(t *testing.T) { + expectGracefulError(t, studyTypes.Expression{Name: "timestampWithOffset", Data: []studyTypes.ExpressionArg{ + newNilArg(t), + }}) + }) + + t.Run("timestampDiff with nil argument", func(t *testing.T) { + expectGracefulError(t, studyTypes.Expression{Name: "timestampDiff", Data: []studyTypes.ExpressionArg{ + newNilArg(t), + {DType: "num", Num: 100}, + }}) + }) + + t.Run("getISOWeekForTs with nil argument", func(t *testing.T) { + expectGracefulError(t, studyTypes.Expression{Name: "getISOWeekForTs", Data: []studyTypes.ExpressionArg{ + newNilArg(t), + }}) + }) + + t.Run("parseValueAsNum with nil argument", func(t *testing.T) { + expectGracefulError(t, studyTypes.Expression{Name: "parseValueAsNum", Data: []studyTypes.ExpressionArg{ + newNilArg(t), + }}) + }) + + t.Run("generateRandomNumber with nil argument", func(t *testing.T) { + expectGracefulError(t, studyTypes.Expression{Name: "generateRandomNumber", Data: []studyTypes.ExpressionArg{ + newNilArg(t), + {DType: "num", Num: 10}, + }}) + }) + + t.Run("sum with nil argument does not panic", func(t *testing.T) { + // sum tolerates unresolved types via a type switch default branch, so it should + // neither panic nor return an error - it simply ignores the nil argument. + exp := studyTypes.Expression{Name: "sum", Data: []studyTypes.ExpressionArg{ + newNilArg(t), + {DType: "num", Num: 1}, + }} + ret, err := ExpressionEval(exp, EvalContext{}) + if err != nil { + t.Errorf("unexpected error: %s", err.Error()) + return + } + if ret.(float64) != 1 { + t.Errorf("unexpected value: %v", ret) + } + }) +} From d78dbac4c12b6b7541344fc8de4b0a954600b96e Mon Sep 17 00:00:00 2001 From: kschneider84 <77678452+kschneider84@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:10:17 +0200 Subject: [PATCH 7/7] Fix test for getTsForNextISOWeek to validate sunday fix --- pkg/study/studyengine/expressions_test.go | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/pkg/study/studyengine/expressions_test.go b/pkg/study/studyengine/expressions_test.go index 1b8b593..5f22233 100644 --- a/pkg/study/studyengine/expressions_test.go +++ b/pkg/study/studyengine/expressions_test.go @@ -3212,15 +3212,16 @@ func TestEvalGetTsForNextISOWeek(t *testing.T) { }) t.Run("result is start of week (Monday midnight), including when reference is a Sunday", func(t *testing.T) { - // Reference is a Sunday (2023-09-10). Before the fix, Weekday() == 0 for Sunday - // caused the week-start calculation to jump forward instead of back to Monday. + // refTs is a Sunday already in the requested ISO week, so the search loop + // exits immediately, exercising the weekday == 0 fix on the original date. refTs := time.Date(2023, 9, 10, 15, 30, 0, 0, time.Local) if refTs.Weekday() != time.Sunday { t.Fatalf("test setup error: reference date is not a Sunday") } + _, isoWeek := refTs.ISOWeek() exp := studyTypes.Expression{Name: "getTsForNextISOWeek", Data: []studyTypes.ExpressionArg{ - {DType: "num", Num: 1}, + {DType: "num", Num: float64(isoWeek)}, {DType: "num", Num: float64(refTs.Unix())}, }} EvalContext := EvalContext{} @@ -3232,11 +3233,9 @@ func TestEvalGetTsForNextISOWeek(t *testing.T) { ts := ret.(float64) tsD := time.Unix(int64(ts), 0) - if tsD.Weekday() != time.Monday { - t.Errorf("unexpected weekday: %s, expected Monday", tsD.Weekday()) - } - if tsD.Hour() != 0 || tsD.Minute() != 0 || tsD.Second() != 0 { - t.Errorf("unexpected time of day: %02d:%02d:%02d, expected midnight", tsD.Hour(), tsD.Minute(), tsD.Second()) + expectedDate := time.Date(2023, 9, 4, 0, 0, 0, 0, time.Local) // Monday of the same ISO week + if !tsD.Equal(expectedDate) { + t.Errorf("unexpected date: %s, expected %s", tsD, expectedDate) } }) }