mirror of
https://github.com/hanzoai/numscript.git
synced 2026-10-30 18:26:57 +00:00
318 lines
8.8 KiB
Go
318 lines
8.8 KiB
Go
package specs_format
|
|
|
|
import (
|
|
"context"
|
|
"math/big"
|
|
"reflect"
|
|
"slices"
|
|
|
|
"github.com/formancehq/numscript/internal/interpreter"
|
|
"github.com/formancehq/numscript/internal/parser"
|
|
"github.com/formancehq/numscript/internal/utils"
|
|
)
|
|
|
|
// --- Specs:
|
|
type Specs struct {
|
|
Schema string `json:"$schema,omitempty"`
|
|
FeatureFlags []string `json:"featureFlags,omitempty"`
|
|
Balances interpreter.Balances `json:"balances,omitempty"`
|
|
Vars interpreter.VariablesMap `json:"variables,omitempty"`
|
|
Meta interpreter.AccountsMetadata `json:"metadata,omitempty"`
|
|
TestCases []TestCase `json:"testCases,omitempty"`
|
|
}
|
|
|
|
type TestCase struct {
|
|
It string `json:"it"`
|
|
|
|
// Preconditions
|
|
Balances interpreter.Balances `json:"balances,omitempty"`
|
|
Vars interpreter.VariablesMap `json:"variables,omitempty"`
|
|
Meta interpreter.AccountsMetadata `json:"metadata,omitempty"`
|
|
|
|
// Select tests
|
|
Focus bool `json:"focus,omitempty"`
|
|
Skip bool `json:"skip,omitempty"`
|
|
|
|
// Expectations
|
|
ExpectMissingFunds bool `json:"expect.error.missingFunds,omitempty"`
|
|
ExpectNegativeAmount bool `json:"expect.error.negativeAmount,omitempty"`
|
|
|
|
ExpectPostings []interpreter.Posting `json:"expect.postings,omitempty"`
|
|
ExpectTxMeta map[string]string `json:"expect.txMetadata,omitempty"`
|
|
ExpectAccountsMeta interpreter.AccountsMetadata `json:"expect.metadata,omitempty"`
|
|
ExpectEndBalances interpreter.Balances `json:"expect.endBalances,omitempty"`
|
|
ExpectEndBalancesInclude interpreter.Balances `json:"expect.endBalances.include,omitempty"`
|
|
ExpectMovements Movements `json:"expect.movements,omitempty"`
|
|
}
|
|
|
|
type TestCaseResult struct {
|
|
Skipped bool `json:"skipped"`
|
|
It string `json:"it"`
|
|
Pass bool `json:"pass"`
|
|
Balances interpreter.Balances `json:"balances"`
|
|
Vars interpreter.VariablesMap `json:"variables"`
|
|
Meta interpreter.AccountsMetadata `json:"metadata"`
|
|
|
|
// Output:
|
|
Postings []interpreter.Posting `json:"postings"`
|
|
|
|
// Assertions
|
|
FailedAssertions []AssertionMismatch[any] `json:"failedAssertions"`
|
|
}
|
|
|
|
type SpecsResult struct {
|
|
// Invariants: total==passing+failing
|
|
Total uint `json:"total"`
|
|
Passing uint `json:"passing"`
|
|
Failing uint `json:"failing"`
|
|
Skipped uint `json:"skipped"`
|
|
Cases []TestCaseResult
|
|
}
|
|
|
|
func runAssertion[T any](failedAssertions []AssertionMismatch[any], assertion string, expected T, got T, cmp func(T, T) bool) []AssertionMismatch[any] {
|
|
eq := cmp(expected, got)
|
|
if !eq {
|
|
return append(failedAssertions, AssertionMismatch[any]{
|
|
Assertion: assertion,
|
|
Expected: expected,
|
|
Got: got,
|
|
})
|
|
}
|
|
|
|
return failedAssertions
|
|
}
|
|
|
|
func Check(program parser.Program, specs Specs) (SpecsResult, interpreter.InterpreterError) {
|
|
specsResult := SpecsResult{}
|
|
// we need a first pass to know whether there is at least on test in focus mode
|
|
hasFocusedTest := slices.ContainsFunc(specs.TestCases, func(t TestCase) bool {
|
|
return t.Focus
|
|
})
|
|
|
|
for _, testCase := range specs.TestCases {
|
|
shouldSkip := testCase.Skip || (hasFocusedTest && !testCase.Focus)
|
|
if shouldSkip {
|
|
specsResult.Skipped += 1
|
|
specsResult.Cases = append(specsResult.Cases, TestCaseResult{
|
|
It: testCase.It,
|
|
Skipped: true,
|
|
})
|
|
continue
|
|
}
|
|
|
|
meta := mergeAccountsMeta(specs.Meta, testCase.Meta)
|
|
balances := mergeBalances(specs.Balances, testCase.Balances)
|
|
vars := mergeVars(specs.Vars, testCase.Vars)
|
|
|
|
featureFlags := make(map[string]struct{})
|
|
for _, flag := range specs.FeatureFlags {
|
|
featureFlags[flag] = struct{}{}
|
|
}
|
|
|
|
result, err := interpreter.RunProgram(
|
|
context.Background(),
|
|
program,
|
|
vars,
|
|
interpreter.StaticStore{
|
|
Meta: meta,
|
|
Balances: balances,
|
|
},
|
|
featureFlags,
|
|
)
|
|
|
|
var failedAssertions []AssertionMismatch[any]
|
|
|
|
if err != nil {
|
|
switch err.(type) {
|
|
case interpreter.MissingFundsErr:
|
|
if !testCase.ExpectMissingFunds {
|
|
failedAssertions = append(failedAssertions, AssertionMismatch[any]{
|
|
Assertion: "expect.error.missingFunds",
|
|
Expected: false,
|
|
Got: true,
|
|
})
|
|
}
|
|
case interpreter.NegativeAmountErr:
|
|
if !testCase.ExpectNegativeAmount {
|
|
failedAssertions = append(failedAssertions, AssertionMismatch[any]{
|
|
Assertion: "expect.error.negativeAmount",
|
|
Expected: false,
|
|
Got: true,
|
|
})
|
|
}
|
|
default:
|
|
return SpecsResult{}, err
|
|
}
|
|
} else {
|
|
|
|
if testCase.ExpectMissingFunds {
|
|
failedAssertions = append(failedAssertions, AssertionMismatch[any]{
|
|
Assertion: "expect.error.missingFunds",
|
|
Expected: true,
|
|
Got: false,
|
|
})
|
|
}
|
|
|
|
if testCase.ExpectNegativeAmount {
|
|
failedAssertions = append(failedAssertions, AssertionMismatch[any]{
|
|
Assertion: "expect.error.negativeAmount",
|
|
Expected: true,
|
|
Got: false,
|
|
})
|
|
}
|
|
|
|
if testCase.ExpectPostings != nil {
|
|
failedAssertions = runAssertion[any](failedAssertions,
|
|
"expect.postings",
|
|
testCase.ExpectPostings,
|
|
result.Postings,
|
|
reflect.DeepEqual,
|
|
)
|
|
}
|
|
|
|
if testCase.ExpectTxMeta != nil {
|
|
metadata := map[string]string{}
|
|
for k, v := range result.Metadata {
|
|
metadata[k] = v.String()
|
|
}
|
|
failedAssertions = runAssertion[any](failedAssertions,
|
|
"expect.txMeta",
|
|
testCase.ExpectTxMeta,
|
|
metadata,
|
|
reflect.DeepEqual,
|
|
)
|
|
}
|
|
|
|
if testCase.ExpectAccountsMeta != nil {
|
|
failedAssertions = runAssertion[any](failedAssertions,
|
|
"expect.accountsMeta",
|
|
testCase.ExpectAccountsMeta,
|
|
result.AccountsMetadata,
|
|
reflect.DeepEqual,
|
|
)
|
|
}
|
|
|
|
if testCase.ExpectEndBalances != nil {
|
|
failedAssertions = runAssertion(failedAssertions,
|
|
"expect.endBalances",
|
|
testCase.ExpectEndBalances,
|
|
getBalances(result.Postings, balances),
|
|
interpreter.CompareBalances,
|
|
)
|
|
}
|
|
|
|
if testCase.ExpectEndBalancesInclude != nil {
|
|
failedAssertions = runAssertion(failedAssertions,
|
|
"expect.endBalances.include",
|
|
testCase.ExpectEndBalancesInclude,
|
|
getBalances(result.Postings, balances),
|
|
interpreter.CompareBalancesIncluding,
|
|
)
|
|
}
|
|
|
|
if testCase.ExpectMovements != nil {
|
|
failedAssertions = runAssertion[any](failedAssertions,
|
|
"expect.movements",
|
|
testCase.ExpectMovements,
|
|
getMovements(result.Postings),
|
|
reflect.DeepEqual,
|
|
)
|
|
}
|
|
|
|
}
|
|
|
|
pass := len(failedAssertions) == 0
|
|
if pass {
|
|
specsResult.Passing += 1
|
|
} else {
|
|
specsResult.Failing += 1
|
|
}
|
|
|
|
var postings []interpreter.Posting
|
|
if result != nil {
|
|
postings = result.Postings
|
|
}
|
|
|
|
specsResult.Cases = append(specsResult.Cases, TestCaseResult{
|
|
It: testCase.It,
|
|
Pass: pass,
|
|
Meta: meta,
|
|
Balances: balances,
|
|
Vars: vars,
|
|
FailedAssertions: failedAssertions,
|
|
Postings: postings,
|
|
})
|
|
}
|
|
|
|
specsResult.Total = specsResult.Failing + specsResult.Passing + specsResult.Skipped
|
|
|
|
return specsResult, nil
|
|
}
|
|
|
|
func mergeVars(v1 interpreter.VariablesMap, v2 interpreter.VariablesMap) interpreter.VariablesMap {
|
|
out := interpreter.VariablesMap{}
|
|
for k, v := range v1 {
|
|
out[k] = v
|
|
}
|
|
for k, v := range v2 {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
func mergeAccountsMeta(m1 interpreter.AccountsMetadata, m2 interpreter.AccountsMetadata) interpreter.AccountsMetadata {
|
|
out := m1.DeepClone()
|
|
out.Merge(m2)
|
|
return out
|
|
}
|
|
|
|
func mergeBalances(b1 interpreter.Balances, b2 interpreter.Balances) interpreter.Balances {
|
|
out := b1.DeepClone()
|
|
out.Merge(b2)
|
|
return out
|
|
}
|
|
|
|
type AssertionMismatch[T any] struct {
|
|
Assertion string `json:"assertion"`
|
|
Expected T `json:"expected,omitempty"`
|
|
Got T `json:"got,omitempty"`
|
|
}
|
|
|
|
// TODO test
|
|
type Movements = map[string]map[string]map[string]*big.Int
|
|
|
|
func getMovements(postings []interpreter.Posting) Movements {
|
|
m := Movements{}
|
|
|
|
for _, posting := range postings {
|
|
assetsMap := utils.NestedMapGetOrPutDefault(m, posting.Source, posting.Destination, func() map[string]*big.Int {
|
|
return map[string]*big.Int{}
|
|
})
|
|
|
|
amt := utils.MapGetOrPutDefault(assetsMap, posting.Asset, func() *big.Int {
|
|
return new(big.Int)
|
|
})
|
|
|
|
amt.Add(amt, posting.Amount)
|
|
}
|
|
|
|
return m
|
|
}
|
|
|
|
func getBalances(postings []interpreter.Posting, initialBalances interpreter.Balances) interpreter.Balances {
|
|
balances := initialBalances.DeepClone()
|
|
for _, posting := range postings {
|
|
sourceBalance := utils.NestedMapGetOrPutDefault(balances, posting.Source, posting.Asset, func() *big.Int {
|
|
return new(big.Int)
|
|
})
|
|
sourceBalance.Sub(sourceBalance, posting.Amount)
|
|
|
|
destinationBalance := utils.NestedMapGetOrPutDefault(balances, posting.Destination, posting.Asset, func() *big.Int {
|
|
return new(big.Int)
|
|
})
|
|
destinationBalance.Add(destinationBalance, posting.Amount)
|
|
}
|
|
|
|
return balances
|
|
}
|