Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ go run ./cmd ./script.ds
- [x] 报错信息优化
- [x] 线程安全
- [x] 变量作用域
- [ ] 测试覆盖率 88% / 90%
- [x] 测试覆盖率 93% / 90%

## 更新记录

Expand Down
33 changes: 32 additions & 1 deletion builtin_functions_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package dicescript

import (
"github.com/stretchr/testify/assert"
"testing"

"github.com/stretchr/testify/assert"
)

func TestNativeFunctionCall(t *testing.T) {
Expand Down Expand Up @@ -177,3 +178,33 @@ func TestNativeFunctionAbs(t *testing.T) {
assert.Error(t, vm.Error)
vm.Error = nil
}

func TestNativeFunctionErrorPropagation(t *testing.T) {
ctx := NewVM()
assert.True(t, valueEqual(funcCeil(ctx, nil, []*VMValue{ni(1)}), ni(1)))

ctx = NewVM()
ctx.Attrs.Store("broken", NewComputedVal("("))
assert.Nil(t, funcLoad(ctx, nil, []*VMValue{ns("broken")}))
assert.Error(t, ctx.Error)

ctx = NewVM()
assert.Nil(t, funcLoadRawAttr(ctx, nil, []*VMValue{ni(1), ni(2)}))
assert.Error(t, ctx.Error)

ctx = NewVM()
obj := NewNativeObjectVal(&NativeObjectData{AttrGet: func(ctx *Context, _ string) *VMValue {
ctx.Error = assert.AnError
return nil
}})
assert.Nil(t, funcLoadRawAttr(ctx, nil, []*VMValue{obj, ns("field")}))
assert.ErrorIs(t, ctx.Error, assert.AnError)

ctx = NewVM()
assert.Nil(t, funcLoadRawItem(ctx, nil, []*VMValue{ni(1), ni(0)}))
assert.Error(t, ctx.Error)

ctx = NewVM()
assert.Nil(t, funcStore(ctx, nil, []*VMValue{ni(1), ni(2)}))
assert.Error(t, ctx.Error)
}
117 changes: 117 additions & 0 deletions custom_dice_parser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package dicescript

import (
"regexp"
"testing"

"github.com/stretchr/testify/assert"
)

func newCustomDiceParserTestState(input string, items ...*customDiceItem) (*parser, *ParserCustomData) {
ctx := NewVM()
ctx.CustomDiceInfo = items
data := &ParserCustomData{
ParserData: ParserData{code: make([]ByteCode, 8)},
ctx: ctx,
}
return newParser("", []byte(input)), data
}

func TestCustomDiceParserSkipsInvalidCandidates(t *testing.T) {
noResult := &customDiceItem{parser: func(*Context, *CustomDiceStream) (*CustomDiceParseResult, error) {
return nil, nil
}}
noConsumption := &customDiceItem{parser: func(*Context, *CustomDiceStream) (*CustomDiceParseResult, error) {
return &CustomDiceParseResult{Matched: true}, nil
}}
matchInput := &customDiceItem{parser: func(_ *Context, stream *CustomDiceStream) (*CustomDiceParseResult, error) {
_, _ = stream.Read()
return &CustomDiceParseResult{Matched: true, Payload: "payload"}, nil
}}
p, data := newCustomDiceParserTestState("X", nil, noResult, noConsumption, matchInput)

match, ok := data.tryMatchCustomDice(p)
assert.True(t, ok)
assert.Equal(t, []string{"X"}, match.groups)
assert.Equal(t, "X", match.text)
assert.Equal(t, "payload", match.payload)
assert.Equal(t, 1, match.byteLen)
assert.Zero(t, data.stream.Consumed(), "matching must not leave the reusable stream advanced")
}

func TestCustomDiceParserFillsEmptyFirstGroup(t *testing.T) {
item := &customDiceItem{parser: func(_ *Context, stream *CustomDiceStream) (*CustomDiceParseResult, error) {
_, _ = stream.Read()
return &CustomDiceParseResult{Matched: true, Groups: []string{"", "capture"}}, nil
}}
p, data := newCustomDiceParserTestState("Z", item)

match, ok := data.tryMatchCustomDice(p)
assert.True(t, ok)
assert.Equal(t, []string{"Z", "capture"}, match.groups)
}

func TestCustomDiceRegexOptionalGroupAndZeroLengthFallback(t *testing.T) {
zeroLength := &customDiceItem{re: regexp.MustCompile(`^`)}
optionalGroup := &customDiceItem{re: regexp.MustCompile(`^X(Y)?`)}
p, data := newCustomDiceParserTestState("X", &customDiceItem{}, zeroLength, optionalGroup)

match, ok := data.tryMatchCustomDice(p)
assert.True(t, ok)
assert.Equal(t, []string{"X", ""}, match.groups)
assert.Equal(t, "X", match.text)

p.pt.offset = len(p.data)
match, ok = data.tryMatchCustomDice(p)
assert.False(t, ok)
assert.Nil(t, match)
}

func TestCustomDicePendingMatchLifecycle(t *testing.T) {
p, data := newCustomDiceParserTestState("X")
assert.Nil(t, data.ConsumeCustomDice(p))
assert.Nil(t, data.CommitCustomDice())

data.pendingCustomDice = &customDiceMatch{startOffset: 0}
assert.Nil(t, data.ConsumeCustomDice(p))
assert.Nil(t, data.pendingCustomDice)

groups := []string{"X", "capture"}
item := &customDiceItem{}
data.pendingCustomDice = &customDiceMatch{
item: item,
groups: groups,
payload: "payload",
}
assert.Nil(t, data.CommitCustomDice())
assert.Equal(t, 1, data.codeIndex)
assert.Equal(t, typeCustomDice, data.code[0].T)
compiled := data.code[0].Value.(*customDiceCompiled)
assert.Same(t, item, compiled.item)
assert.Equal(t, "X", compiled.text)
assert.Equal(t, "payload", compiled.payload)
assert.Equal(t, groups, compiled.groups)
groups[0] = "changed"
assert.Equal(t, "X", compiled.groups[0], "committed groups must be an immutable snapshot")

assert.Nil(t, cloneStrings(nil))
assert.Nil(t, (*ParserCustomData)(nil).ensurePendingCustomDice(p))
match, ok := (*ParserCustomData)(nil).tryMatchCustomDice(p)
assert.False(t, ok)
assert.Nil(t, match)
}

func TestCustomDiceEnsurePendingRefreshesAtCurrentOffset(t *testing.T) {
item := &customDiceItem{re: regexp.MustCompile(`^X`)}
p, data := newCustomDiceParserTestState("X", item)
data.pendingCustomDice = &customDiceMatch{startOffset: 1}

match := data.ensurePendingCustomDice(p)
assert.NotNil(t, match)
assert.Same(t, match, data.pendingCustomDice)

p.data = []byte("Z")
data.pendingCustomDice = &customDiceMatch{startOffset: 1}
assert.Nil(t, data.ensurePendingCustomDice(p))
assert.Nil(t, data.pendingCustomDice)
}
73 changes: 73 additions & 0 deletions custom_dice_stream_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package dicescript

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestCustomDiceStreamNavigation(t *testing.T) {
var stream CustomDiceStream
stream.init([]byte("x甲12!"), 1)

r, ok := stream.Peek()
assert.True(t, ok)
assert.Equal(t, '甲', r)
assert.Zero(t, stream.Consumed())

r, ok = stream.Read()
assert.True(t, ok)
assert.Equal(t, '甲', r)
assert.Equal(t, "甲", stream.Current())
assert.Equal(t, "12!", stream.Remaining())
assert.True(t, stream.Unread())
assert.False(t, stream.Unread())

_, _ = stream.Read()
digits, ok := stream.ReadDigits()
assert.True(t, ok)
assert.Equal(t, "12", digits)
stream.Commit()
assert.Equal(t, "甲12", stream.Current())

digits, ok = stream.ReadDigits()
assert.False(t, ok)
assert.Empty(t, digits)
r, ok = stream.Read()
assert.True(t, ok)
assert.Equal(t, '!', r)
_, ok = stream.Peek()
assert.False(t, ok)
_, ok = stream.Read()
assert.False(t, ok)

stream.ResetAttempt()
assert.Zero(t, stream.Consumed())
assert.Equal(t, "甲12!", stream.Remaining())
}

func TestCustomDiceStreamInvalidUTF8AndReadExprErrors(t *testing.T) {
var stream CustomDiceStream
stream.init([]byte{0xff}, 0)

r, ok := stream.Peek()
assert.True(t, ok)
assert.Equal(t, rune(0xff), r)
r, ok = stream.Read()
assert.True(t, ok)
assert.Equal(t, rune(0xff), r)
assert.True(t, stream.Unread())

stream.init(nil, 0)
value, matched, err := stream.ReadExpr("")
assert.NoError(t, err)
assert.False(t, matched)
assert.Nil(t, value)

stream.init([]byte("1"), 0)
value, matched, err = stream.ReadExpr("not-a-rule")
assert.Error(t, err)
assert.False(t, matched)
assert.Nil(t, value)
assert.Zero(t, stream.Consumed())
}
78 changes: 78 additions & 0 deletions parser_errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,3 +248,81 @@ func TestGetPrevNonSpaceChar(t *testing.T) {
}
}
}

func TestFriendlyErrorClassification(t *testing.T) {
tests := []struct {
name string
input string
offset int
want string
}{
{name: "empty", want: "Empty input"},
{name: "template if", input: "{ if }", offset: 5, want: "Incomplete if statement inside"},
{name: "if", input: "if true", offset: 7, want: "Incomplete if statement"},
{name: "right brace", input: "{1", offset: 2, want: "Missing closing brace"},
{name: "right bracket", input: "[1", offset: 2, want: "Missing closing bracket"},
{name: "missing expression", input: "1 +", offset: 3, want: "Expression expected after '+'"},
{name: "incomplete", input: "value", offset: 5, want: "Incomplete expression"},
{name: "unclosed string", input: "a\"", offset: 1, want: "Unclosed string literal"},
{name: "unexpected character", input: "a@", offset: 1, want: "Unexpected character '@'"},
{name: "generic syntax", input: "value", offset: 0, want: "Syntax error"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := formatFriendlyErrorForLanguage(
ParseErrorLanguageEnglish,
position{line: 1, col: tt.offset + 1, offset: tt.offset},
[]byte(tt.input),
nil,
)
assert.Contains(t, err.Error(), tt.want)
})
}
}

func TestFriendlyErrorGlobalLanguageAndParserFallback(t *testing.T) {
previousLanguage := parseErrorLanguage
t.Cleanup(func() { SetParseErrorLanguage(previousLanguage) })

SetParseErrorLanguage(ParseErrorLanguageEnglish)
err := formatFriendlyError(position{line: 1, col: 1}, []byte("/"), nil)
assert.Contains(t, err.Error(), "Syntax Error")
assert.NotContains(t, err.Error(), "语法错误")

fallback := assert.AnError
assert.Equal(t, fallback, formatFriendlyParseError(ParseErrorLanguageEnglish, nil, nil, fallback))

p := newParser("", []byte("/"))
assert.Equal(t, fallback, formatFriendlyParseError(ParseErrorLanguageEnglish, p, []byte("/"), fallback))
p.maxFailExpected = []string{"number", "number", "identifier"}
p.maxFailPos = position{line: 1, col: 1, offset: 0}
err = formatFriendlyParseError(ParseErrorLanguageEnglish, p, []byte("/"), fallback)
assert.Contains(t, err.Error(), "Expression cannot start with '/'")

first := parseErrorFormatterOption(ParseErrorLanguageChinese)
second := first(p)
third := second(p)
assert.Nil(t, third)
}

func TestParseErrorDetectionEdgeCases(t *testing.T) {
assert.False(t, detectIfSyntaxError(nil, position{}))
assert.False(t, detectIfSyntaxError([]byte("while true"), position{}))
assert.False(t, detectIfSyntaxError([]byte("iffy"), position{}))
assert.False(t, detectIfSyntaxError([]byte("if true {}"), position{}))
assert.True(t, detectIfSyntaxError([]byte("if true"), position{}))

assert.False(t, detectTemplateIfSyntaxError([]byte("if"), position{}))
assert.False(t, detectTemplateIfSyntaxError([]byte("{ value }"), position{}))
assert.False(t, detectTemplateIfSyntaxError([]byte("{ if }"), position{offset: 2}))
assert.False(t, detectTemplateIfSyntaxError([]byte("{ ifx}"), position{offset: 5}))
assert.True(t, detectTemplateIfSyntaxError([]byte("{% if}"), position{offset: 5}))

assert.Equal(t, "short", getLineAtBytes([]byte("short"), 0))
longInput := []byte(strings.Repeat("x", 80))
assert.Equal(t, strings.Repeat("x", 57)+"...", getLineAtBytes(longInput, 2))
assert.False(t, isValidIdentChar('@'))
assert.True(t, isOperatorChar('+'))
assert.Zero(t, findUnclosedBracketBytes([]byte("]})")))
}
19 changes: 18 additions & 1 deletion types_functions_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package dicescript

import (
"github.com/stretchr/testify/assert"
"testing"

"github.com/stretchr/testify/assert"
)

func TestTypesFuncDict(t *testing.T) {
Expand All @@ -29,6 +30,22 @@ func TestTypesFuncDictToStr(t *testing.T) {
assert.Equal(t, d.ToString(), "{'a': 1}")
}

func TestTypesFuncDictRange(t *testing.T) {
d := NewDictValWithArrayMust(ns("a"), ni(1), ns("b"), ni(2))
seen := map[string]IntType{}
d.Range(func(key string, value *VMValue) bool {
seen[key] = value.MustReadInt()
return true
})
assert.Equal(t, map[string]IntType{"a": 1, "b": 2}, seen)

invalid := (*VMDictValue)(ni(2))
invalid.Range(func(string, *VMValue) bool {
t.Fatal("callback must not run for a non-dict value")
return false
})
}

func TestTypesFuncArray(t *testing.T) {
vm := NewVM()
arr := na(ni(1), ni(2), ni(3))
Expand Down
Loading
Loading