test: add comprehensive VM opcode tests (#750)

Add extensive test coverage for VM opcodes including:

- Basic operations (OpLoadEnv, OpTrue, OpFalse, OpNil)
- Arithmetic operations (OpNegate, OpExponent)
- String operations (OpEqualString, OpMatches)
- Collection operations (OpIn, OpLen)
- Type operations (OpCast)
- Control flow (OpThrow)
- Function calls (OpCall0-3, OpCallN)
- Index and count manipulation (OpGetIndex, OpSetIndex)
- Profiling operations (OpProfileStart, OpProfileEnd)

Also add high-level expression tests for:
- Arithmetic expressions
- String operations
- Data structure operations
- Group and sort operations

Each test case includes detailed comments and covers both success and error cases.

These tests combined with a debugger helped me navigate the VM internals
significantly.

Signed-off-by: Ville Vesilehto <[email protected]>
This commit is contained in:
Ville Vesilehto
2025-01-27 11:26:26 +01:00
committed by GitHub
parent 74190e1e81
commit ac2e66320d
+932
View File
@@ -259,3 +259,935 @@ func TestRun_OpInvalid(t *testing.T) {
_, err := vm.Run(program, nil)
require.EqualError(t, err, "invalid opcode")
}
func TestVM_OpcodeOperations(t *testing.T) {
tests := []struct {
name string
expr string
env map[string]any
want any
expectError string
}{
// Arithmetic Operations
{
name: "basic addition",
expr: "2 + 3",
want: 5,
},
{
name: "mixed type arithmetic",
expr: "2.5 + 3",
want: 5.5,
},
{
name: "chained arithmetic",
expr: "1 + 2 * 3 - 4 / 2",
want: 5.0,
},
{
name: "modulo operation",
expr: "5 % 2",
want: 1,
},
{
name: "exponent operation",
expr: "2 ^ 3",
want: 8.0,
},
{
name: "negation",
expr: "-5",
want: -5,
},
// String Operations
{
name: "string concatenation",
expr: `"hello" + " " + "world"`,
want: "hello world",
},
{
name: "string starts with",
expr: `"hello world" startsWith "hello"`,
want: true,
},
{
name: "string ends with",
expr: `"hello world" endsWith "world"`,
want: true,
},
{
name: "string contains",
expr: `"hello world" contains "lo wo"`,
want: true,
},
{
name: "string matches regex",
expr: `"hello123" matches "^hello\\d+$"`,
want: true,
},
// Data Structure Operations
{
name: "array creation and access",
expr: "[1, 2, 3][1]",
want: 2,
},
{
name: "map creation and access",
expr: `{"a": 1, "b": 2}.b`,
want: 2,
},
{
name: "array length",
expr: "len([1, 2, 3])",
want: 3,
},
{
name: "array slice",
expr: "[1, 2, 3, 4][1:3]",
want: []any{2, 3},
},
{
name: "array range",
expr: "1..5",
want: []int{1, 2, 3, 4, 5},
},
// Error Cases
{
name: "invalid array index",
expr: "[1,2,3][5]",
expectError: "index out of range",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
program, err := expr.Compile(tt.expr, expr.Env(tt.env))
require.NoError(t, err)
testVM := &vm.VM{}
got, err := testVM.Run(program, tt.env)
if tt.expectError != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.expectError)
} else {
require.NoError(t, err)
require.Equal(t, tt.want, got)
}
})
}
}
func TestVM_GroupAndSortOperations(t *testing.T) {
tests := []struct {
name string
expr string
env map[string]any
want any
expectError string
}{
{
name: "group by single field",
expr: `groupBy([{"id": 1, "type": "a"}, {"id": 2, "type": "b"}, {"id": 3, "type": "a"}], #.type)`,
want: map[any][]any{
"a": {
map[string]any{"id": 1, "type": "a"},
map[string]any{"id": 3, "type": "a"},
},
"b": {
map[string]any{"id": 2, "type": "b"},
},
},
},
{
name: "sort by field ascending",
expr: `sortBy([{"id": 3}, {"id": 1}, {"id": 2}], #.id)`,
want: []any{
map[string]any{"id": 1},
map[string]any{"id": 2},
map[string]any{"id": 3},
},
},
{
name: "sort by field descending",
expr: `sortBy([{"id": 3}, {"id": 1}, {"id": 2}], #.id, "desc")`,
want: []any{
map[string]any{"id": 3},
map[string]any{"id": 2},
map[string]any{"id": 1},
},
},
{
name: "sort by computed value",
expr: `sortBy([1, 2, 3, 4], # % 2)`,
want: []any{2, 4, 1, 3},
},
{
name: "group by with complex key",
expr: `groupBy([1, 2, 3, 4, 5, 6], # % 2 == 0 ? "even" : "odd")`,
want: map[any][]any{
"even": {2, 4, 6},
"odd": {1, 3, 5},
},
},
{
name: "invalid sort order",
expr: `sortBy([1, 2, 3], #, "invalid")`,
expectError: "unknown order",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
program, err := expr.Compile(tt.expr, expr.Env(tt.env))
require.NoError(t, err)
testVM := &vm.VM{}
got, err := testVM.Run(program, tt.env)
if tt.expectError != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.expectError)
} else {
require.NoError(t, err)
require.Equal(t, tt.want, got)
}
})
}
}
// TestVM_ProfileOperations tests the profiling opcodes
func TestVM_ProfileOperations(t *testing.T) {
program := &vm.Program{
Bytecode: []vm.Opcode{
vm.OpProfileStart,
vm.OpPush,
vm.OpProfileEnd,
},
Arguments: []int{0, 0, 0},
Constants: []any{
&vm.Span{},
},
}
testVM := &vm.VM{}
_, err := testVM.Run(program, nil)
require.NoError(t, err)
span := program.Constants[0].(*vm.Span)
require.True(t, span.Duration > 0, "Profile duration should be greater than 0")
}
// TestVM_IndexOperations tests the index manipulation opcodes
func TestVM_IndexOperations(t *testing.T) {
tests := []struct {
name string
expr string
want any
}{
{
name: "decrement index in loop",
expr: "reduce([1,2,3], #acc + #, 0)",
want: 6,
},
{
name: "set index in loop",
expr: "map([1,2,3], # * 2)",
want: []any{2, 4, 6},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
program, err := expr.Compile(tt.expr)
require.NoError(t, err)
testVM := &vm.VM{}
got, err := testVM.Run(program, nil)
require.NoError(t, err)
require.Equal(t, tt.want, got)
})
}
}
// TestVM_DirectCallOpcodes tests the specialized call opcodes directly
func TestVM_DirectCallOpcodes(t *testing.T) {
tests := []struct {
name string
bytecode []vm.Opcode
args []int
consts []any
funcs []vm.Function
want any
wantErr bool
}{
{
name: "OpCall0",
bytecode: []vm.Opcode{vm.OpCall0},
args: []int{0},
funcs: []vm.Function{
func(args ...any) (any, error) {
return 42, nil
},
},
want: 42,
},
{
name: "OpCall1",
bytecode: []vm.Opcode{
vm.OpPush,
vm.OpCall1,
},
args: []int{0, 0},
consts: []any{10},
funcs: []vm.Function{
func(args ...any) (any, error) {
return args[0].(int) * 2, nil
},
},
want: 20,
},
{
name: "OpCall2",
bytecode: []vm.Opcode{
vm.OpPush,
vm.OpPush,
vm.OpCall2,
},
args: []int{0, 1, 0},
consts: []any{10, 5},
funcs: []vm.Function{
func(args ...any) (any, error) {
return args[0].(int) + args[1].(int), nil
},
},
want: 15,
},
{
name: "OpCall3",
bytecode: []vm.Opcode{
vm.OpPush,
vm.OpPush,
vm.OpPush,
vm.OpCall3,
},
args: []int{0, 1, 2, 0},
consts: []any{10, 5, 2},
funcs: []vm.Function{
func(args ...any) (any, error) {
return args[0].(int) + args[1].(int) + args[2].(int), nil
},
},
want: 17,
},
{
name: "OpCallN with error",
bytecode: []vm.Opcode{
vm.OpLoadFunc,
vm.OpCallN,
},
args: []int{0, 0}, // Function index, number of args (0)
funcs: []vm.Function{
func(args ...any) (any, error) {
return nil, fmt.Errorf("test error")
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
program := vm.NewProgram(
nil, // source
nil, // node
nil, // locations
0, // variables
tt.consts,
tt.bytecode,
tt.args,
tt.funcs,
nil, // debugInfo
nil, // span
)
vm := &vm.VM{}
got, err := vm.Run(program, nil)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tt.want, got)
}
})
}
}
func TestVM_CallN(t *testing.T) {
input := `fn(1, 2, 3)`
tree, err := parser.Parse(input)
require.NoError(t, err)
env := map[string]any{
"fn": func(args ...any) (any, error) {
sum := 0
for _, arg := range args {
sum += arg.(int)
}
return sum, nil
},
}
config := conf.New(env)
program, err := compiler.Compile(tree, config)
require.NoError(t, err)
out, err := vm.Run(program, env)
require.NoError(t, err)
require.Equal(t, 6, out)
}
// TestVM_IndexAndCountOperations tests the index and count manipulation opcodes directly
func TestVM_IndexAndCountOperations(t *testing.T) {
tests := []struct {
name string
bytecode []vm.Opcode
args []int
consts []any
want any
wantErr bool
}{
{
name: "GetIndex",
bytecode: []vm.Opcode{
vm.OpPush, // Push array to stack
vm.OpBegin, // Start scope
vm.OpGetIndex, // Get current index
},
args: []int{0, 0, 0},
consts: []any{[]any{1, 2, 3}}, // Array for scope
want: 0, // Initial index is 0
},
{
name: "DecrementIndex",
bytecode: []vm.Opcode{
vm.OpPush, // Push array to stack
vm.OpBegin, // Start scope
vm.OpDecrementIndex, // Decrement index
vm.OpGetIndex, // Get current index
},
args: []int{0, 0, 0, 0},
consts: []any{[]any{1, 2, 3}}, // Array for scope
want: -1, // After decrement
},
{
name: "GetCount",
bytecode: []vm.Opcode{
vm.OpPush, // Push array to stack
vm.OpBegin, // Start scope
vm.OpGetCount, // Get current count
},
args: []int{0, 0, 0},
consts: []any{[]any{1, 2, 3}}, // Array for scope
want: 0, // Initial count is 0
},
{
name: "IncrementCount",
bytecode: []vm.Opcode{
vm.OpPush, // Push array to stack
vm.OpBegin, // Start scope
vm.OpIncrementCount, // Increment count
vm.OpGetCount, // Get current count
},
args: []int{0, 0, 0, 0},
consts: []any{[]any{1, 2, 3}}, // Array for scope
want: 1, // After increment
},
{
name: "Multiple operations",
bytecode: []vm.Opcode{
vm.OpPush, // Push array to stack
vm.OpBegin, // Start scope
vm.OpIncrementCount, // Count = 1
vm.OpIncrementCount, // Count = 2
vm.OpDecrementIndex, // Index = -1
vm.OpDecrementIndex, // Index = -2
vm.OpGetCount, // Push count (2)
vm.OpGetIndex, // Push index (-2)
vm.OpAdd, // Add them together
},
args: []int{0, 0, 0, 0, 0, 0, 0, 0, 0},
consts: []any{[]any{1, 2, 3}}, // Array for scope
want: 0, // 2 + (-2) = 0
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
program := vm.NewProgram(
nil, // source
nil, // node
nil, // locations
0, // variables
tt.consts,
tt.bytecode,
tt.args,
nil, // functions
nil, // debugInfo
nil, // span
)
vm := &vm.VM{}
got, err := vm.Run(program, nil)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tt.want, got)
}
})
}
}
// TestVM_DirectBasicOpcodes tests basic opcodes directly
func TestVM_DirectBasicOpcodes(t *testing.T) {
tests := []struct {
name string
bytecode []vm.Opcode
args []int
consts []any
env any
want any
wantErr bool
}{
{
name: "OpLoadEnv",
bytecode: []vm.Opcode{
vm.OpLoadEnv, // Load entire environment
},
args: []int{0},
env: map[string]any{"key": "value"},
want: map[string]any{"key": "value"},
},
{
name: "OpTrue",
bytecode: []vm.Opcode{
vm.OpTrue,
},
args: []int{0},
want: true,
},
{
name: "OpFalse",
bytecode: []vm.Opcode{
vm.OpFalse,
},
args: []int{0},
want: false,
},
{
name: "OpNil",
bytecode: []vm.Opcode{
vm.OpNil,
},
args: []int{0},
want: nil,
},
{
name: "OpNegate int",
bytecode: []vm.Opcode{
vm.OpPush, // Push number
vm.OpNegate, // Negate it
},
args: []int{0, 0},
consts: []any{42},
want: -42,
},
{
name: "OpNegate float",
bytecode: []vm.Opcode{
vm.OpPush, // Push number
vm.OpNegate, // Negate it
},
args: []int{0, 0},
consts: []any{42.5},
want: -42.5,
},
{
name: "OpNot true",
bytecode: []vm.Opcode{
vm.OpTrue, // Push true
vm.OpNot, // Negate it
},
args: []int{0, 0},
want: false,
},
{
name: "OpNot false",
bytecode: []vm.Opcode{
vm.OpFalse, // Push false
vm.OpNot, // Negate it
},
args: []int{0, 0},
want: true,
},
{
name: "OpNot error",
bytecode: []vm.Opcode{
vm.OpPush, // Push non-bool
vm.OpNot, // Try to negate it
},
args: []int{0, 0},
consts: []any{"not a bool"},
wantErr: true,
},
{
name: "OpEqualString equal",
bytecode: []vm.Opcode{
vm.OpPush, // Push first string
vm.OpPush, // Push second string
vm.OpEqualString, // Compare strings
},
args: []int{0, 1, 0},
consts: []any{"hello", "hello"},
want: true,
},
{
name: "OpEqualString not equal",
bytecode: []vm.Opcode{
vm.OpPush, // Push first string
vm.OpPush, // Push second string
vm.OpEqualString, // Compare strings
},
args: []int{0, 1, 0},
consts: []any{"hello", "world"},
want: false,
},
{
name: "OpEqualString with empty strings",
bytecode: []vm.Opcode{
vm.OpPush, // Push first string
vm.OpPush, // Push second string
vm.OpEqualString, // Compare strings
},
args: []int{0, 1, 0},
consts: []any{"", ""},
want: true,
},
{
name: "OpEqualString type error",
bytecode: []vm.Opcode{
vm.OpPush, // Push non-string
vm.OpPush, // Push string
vm.OpEqualString, // Try to compare
},
args: []int{0, 1, 0},
consts: []any{42, "hello"},
wantErr: true,
},
{
name: "OpInt",
bytecode: []vm.Opcode{
vm.OpInt, // Push int directly from args
},
args: []int{42}, // The value 42 is passed directly in args
consts: []any{}, // No constants needed
want: 42,
},
{
name: "OpInt negative",
bytecode: []vm.Opcode{
vm.OpInt, // Push negative int directly from args
},
args: []int{-42}, // The value -42 is passed directly in args
consts: []any{}, // No constants needed
want: -42,
},
{
name: "OpInt zero",
bytecode: []vm.Opcode{
vm.OpInt, // Push zero directly from args
},
args: []int{0}, // The value 0 is passed directly in args
consts: []any{}, // No constants needed
want: 0,
},
{
name: "OpIn array true",
bytecode: []vm.Opcode{
vm.OpPush, // Push element
vm.OpPush, // Push array
vm.OpIn, // Check if element is in array
},
args: []int{0, 1, 0},
consts: []any{2, []any{1, 2, 3}},
want: true,
},
{
name: "OpIn array false",
bytecode: []vm.Opcode{
vm.OpPush, // Push element
vm.OpPush, // Push array
vm.OpIn, // Check if element is in array
},
args: []int{0, 1, 0},
consts: []any{4, []any{1, 2, 3}},
want: false,
},
{
name: "OpIn map true",
bytecode: []vm.Opcode{
vm.OpPush, // Push key
vm.OpPush, // Push map
vm.OpIn, // Check if key is in map
},
args: []int{0, 1, 0},
consts: []any{"b", map[string]any{"a": 1, "b": 2}},
want: true,
},
{
name: "OpIn map false",
bytecode: []vm.Opcode{
vm.OpPush, // Push key
vm.OpPush, // Push map
vm.OpIn, // Check if key is in map
},
args: []int{0, 1, 0},
consts: []any{"c", map[string]any{"a": 1, "b": 2}},
want: false,
},
{
name: "OpExponent integers",
bytecode: []vm.Opcode{
vm.OpPush, // Push base
vm.OpPush, // Push exponent
vm.OpExponent, // Calculate power
},
args: []int{0, 1, 0},
consts: []any{2, 3},
want: 8.0,
},
{
name: "OpExponent floats",
bytecode: []vm.Opcode{
vm.OpPush, // Push base
vm.OpPush, // Push exponent
vm.OpExponent, // Calculate power
},
args: []int{0, 1, 0},
consts: []any{2.0, 3.0},
want: 8.0,
},
{
name: "OpExponent negative exponent",
bytecode: []vm.Opcode{
vm.OpPush, // Push base
vm.OpPush, // Push exponent
vm.OpExponent, // Calculate power
},
args: []int{0, 1, 0},
consts: []any{2.0, -2.0},
want: 0.25,
},
{
name: "OpMatches valid regex",
bytecode: []vm.Opcode{
vm.OpPush, // Push string
vm.OpPush, // Push pattern
vm.OpMatches, // Match string against pattern
},
args: []int{0, 1, 0},
consts: []any{"hello123", "^hello\\d+$"},
want: true,
},
{
name: "OpMatches non-matching regex",
bytecode: []vm.Opcode{
vm.OpPush, // Push string
vm.OpPush, // Push pattern
vm.OpMatches, // Match string against pattern
},
args: []int{0, 1, 0},
consts: []any{"hello", "^\\d+$"},
want: false,
},
{
name: "OpMatches invalid regex",
bytecode: []vm.Opcode{
vm.OpPush, // Push string
vm.OpPush, // Push pattern
vm.OpMatches, // Match string against pattern
},
args: []int{0, 1, 0},
consts: []any{"hello", "[invalid"},
wantErr: true,
},
{
name: "OpMatches type error",
bytecode: []vm.Opcode{
vm.OpPush, // Push non-string
vm.OpPush, // Push pattern
vm.OpMatches, // Match against pattern
},
args: []int{0, 1, 0},
consts: []any{42, "^\\d+$"},
wantErr: true,
},
{
name: "OpCast int to float64",
bytecode: []vm.Opcode{
vm.OpPush, // Push int
vm.OpCast, // Cast to float64
},
args: []int{0, 2},
consts: []any{42},
want: float64(42),
},
{
name: "OpCast int32 to int64",
bytecode: []vm.Opcode{
vm.OpPush, // Push int32
vm.OpCast, // Cast to int64
},
args: []int{0, 1},
consts: []any{int32(42)},
want: int64(42),
},
{
name: "OpCast invalid type",
bytecode: []vm.Opcode{
vm.OpPush, // Push string
vm.OpCast, // Try to cast to float64
},
args: []int{0, 0},
consts: []any{"not a number"},
wantErr: true,
},
{
name: "OpLen array",
bytecode: []vm.Opcode{
vm.OpPush, // Push array
vm.OpLen, // Get length
},
args: []int{0, 0},
consts: []any{[]any{1, 2, 3}},
want: 3,
},
{
name: "OpLen empty array",
bytecode: []vm.Opcode{
vm.OpPush, // Push empty array
vm.OpLen, // Get length
},
args: []int{0, 0},
consts: []any{[]any{}},
want: 0,
},
{
name: "OpLen string",
bytecode: []vm.Opcode{
vm.OpPush, // Push string
vm.OpLen, // Get length
},
args: []int{0, 0},
consts: []any{"hello"},
want: 5,
},
{
name: "OpLen empty string",
bytecode: []vm.Opcode{
vm.OpPush, // Push empty string
vm.OpLen, // Get length
},
args: []int{0, 0},
consts: []any{""},
want: 0,
},
{
name: "OpLen map",
bytecode: []vm.Opcode{
vm.OpPush, // Push map
vm.OpLen, // Get length
},
args: []int{0, 0},
consts: []any{map[string]any{"a": 1, "b": 2, "c": 3}},
want: 3,
},
{
name: "OpLen empty map",
bytecode: []vm.Opcode{
vm.OpPush, // Push empty map
vm.OpLen, // Get length
},
args: []int{0, 0},
consts: []any{map[string]any{}},
want: 0,
},
{
name: "OpLen invalid type",
bytecode: []vm.Opcode{
vm.OpPush, // Push number
vm.OpLen, // Try to get length
},
args: []int{0, 0},
consts: []any{42},
wantErr: true,
},
{
name: "OpThrow with string",
bytecode: []vm.Opcode{
vm.OpPush, // Push error message
vm.OpThrow, // Throw error
},
args: []int{0, 0},
consts: []any{"test error"},
wantErr: true,
},
{
name: "OpThrow with error",
bytecode: []vm.Opcode{
vm.OpPush, // Push error
vm.OpThrow, // Throw error
},
args: []int{0, 0},
consts: []any{fmt.Errorf("test error")},
wantErr: true,
},
{
name: "OpDefault",
bytecode: []vm.Opcode{
vm.OpEnd + 1, // OpEnd is always last, this is anunknown opcode
},
args: []int{0, 0},
consts: []any{fmt.Errorf("test error")},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
program := vm.NewProgram(
nil, // source
nil, // node
nil, // locations
0, // variables
tt.consts,
tt.bytecode,
tt.args,
nil, // functions
nil, // debugInfo
nil, // span
)
vm := &vm.VM{}
got, err := vm.Run(program, tt.env)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tt.want, got)
}
})
}
}