This repository has been archived on 2024-02-28. You can view files and clone it, but cannot push or open issues or pull requests.
stick/exec_test.go
2023-03-31 23:59:16 -06:00

460 lines
14 KiB
Go

package stick
import (
"bytes"
"fmt"
"io"
"strings"
"testing"
"github.com/tyler-sommer/stick/parse"
)
// A testValidator checks that the result of a test execution is as expected.
type testValidator func(actual string, err error) error
// execTest is a configurable template execution test.
type execTest struct {
name string
tpl string
ctx map[string]Value
checkResult testValidator
visitNode func(parse.Node) // When visitNode is set, it will be called after each node is parsed.
}
type testOption func(t *execTest)
func newExecTest(name string, tpl string, v testValidator, opts ...testOption) execTest {
t := execTest{name: name, tpl: tpl, checkResult: v}
for _, o := range opts {
o(&t)
}
return t
}
// withContext sets the context variables for the template.
func withContext(ctx map[string]Value) testOption {
return func(t *execTest) {
t.ctx = ctx
}
}
// withNodeVisitor enhances a test with the ability to inspect parsed nodes.
//
// For the purposes of this testing, this provides the ability to muck around with the internal
// structure of a template. This can be used to create an invalid tree that would not normally
// be parsed, but may be necessary to test a certain error condition.
func withNodeVisitor(visitFunc func(parse.Node)) testOption {
return func(t *execTest) {
t.visitNode = visitFunc
}
}
var tests = []execTest{
newExecTest("Hello, World", "Hello, World!", expect("Hello, World!")),
newExecTest("Hello, Tyler!", "Hello, {{ name }}!", expect("Hello, Tyler!"), withContext(map[string]Value{"name": "Tyler"})),
newExecTest("Simple if", `<li class="{% if active %}active{% endif %}">`, expect(`<li class="active">`), withContext(map[string]Value{"active": true})),
newExecTest("Simple inheritance", `{% extends 'Hello, {% block test %}universe{% endblock %}!' %}{% block test %}world{% endblock %}`, expect(`Hello, world!`)),
newExecTest("Simple include", `This is a test. {% include 'Hello, {{ name }}!' %} This concludes the test.`, expect(`This is a test. Hello, John! This concludes the test.`), withContext(map[string]Value{"name": "John"})),
newExecTest("Include with", `{% include 'Hello, {{ name }}{{ value }}' with vars %}`, expect(`Hello, Adam!`), withContext(map[string]Value{"value": "!", "vars": map[string]Value{"name": "Adam"}})),
newExecTest("Include with literal", `{% include 'Hello, {{ name }}{{ value }}' with {"name": "world", "value": "!"} only %}`, expect(`Hello, world!`)),
newExecTest("Embed", `Well. {% embed 'Hello, {% block name %}World{% endblock %}!' %}{% block name %}Tyler{% endblock %}{% endembed %}`, expect(`Well. Hello, Tyler!`)),
newExecTest("Constant null", `{% if test == null %}Yes{% else %}no{% endif %}`, expect(`Yes`), withContext(map[string]Value{"test": nil})),
newExecTest("Constant bool", `{% if test == true %}Yes{% else %}no{% endif %}`, expect(`no`), withContext(map[string]Value{"test": false})),
newExecTest("Chained attributes", `{{ entity.attr.Name }}`, expect(`Tyler`), withContext(map[string]Value{"entity": map[string]Value{"attr": struct{ Name string }{"Tyler"}}})),
newExecTest("Attribute method call", `{{ entity.Name('lower') }}`, expect(`lowerJohnny`), withContext(map[string]Value{"entity": &fakePerson{"Johnny"}})),
newExecTest("For loop", `{% for i in 1..3 %}{{ i }}{% endfor %}`, expect(`123`)),
newExecTest(
"For loop with inner loop",
`{% for i in test %}{% for j in i %}{{ j }}{{ loop.index }}{{ loop.parent.index }}{% if loop.first %},{% endif %}{% if loop.last %};{% endif %}{% endfor %}{% if loop.first %}f{% endif %}{% if loop.last %}l{% endif %}:{% endfor %}`,
expect(`111,221331;f:412,522632;:713,823933;l:`),
withContext(map[string]Value{
"test": [][]int{
{1, 2, 3},
{4, 5, 6},
{7, 8, 9}},
}),
),
newExecTest(
"For loop variables",
`{% for i in 1..3 %}{{ i }}{{ loop.index }}{{ loop.index0 }}{{ loop.revindex }}{{ loop.revindex0 }}{{ loop.length }}{% if loop.first %}f{% endif %}{% if loop.last %}l{% endif %}{% endfor %}`,
expect(`110323f221213332103l`),
),
newExecTest("For else", `{% for i in emptySet %}{{ i }}{% else %}No results.{% endfor %}`, expect(`No results.`), withContext(map[string]Value{"emptySet": []int{}})),
newExecTest(
"For map",
`{% for k, v in data %}Record {{ loop.index }}: {{ k }}: {{ v }}{% if not loop.last %} - {% endif %}{% endfor %}`,
expect(`Record 1: Group A: 5.12 - Record 2: Group B: 5.09`, `Record 1: Group B: 5.09 - Record 2: Group A: 5.12`),
withContext(map[string]Value{"data": map[string]float64{"Group A": 5.12, "Group B": 5.09}}),
),
newExecTest(
"Some operators",
`{{ 4.5 * 10 }} - {{ 3 + true }} - {{ 3 + 4 == 7.0 }} - {{ 10 % 2 == 0 }} - {{ 10 ** 2 > 99.9 and 10 ** 2 <= 100 }}`,
expect(`45 - 4 - 1 - 1 - 1`),
),
newExecTest("In and not in", `{{ 5 in set and 4 not in set }}`, expect(`1`), withContext(map[string]Value{"set": []int{5, 10}})),
newExecTest("Function call", `{{ multiply(num, 5) }}`, expect(`50`), withContext(map[string]Value{"num": 10})),
newExecTest("Filter call", `Welcome, {{ name }}`, expect(`Welcome, `)),
newExecTest("Filter call", `Welcome, {{ name|default('User') }}`, expect(`Welcome, User`), withContext(map[string]Value{"name": nil})),
newExecTest("Filter call", `Welcome, {{ surname|default('User') }}`, expect(`Welcome, User`), withContext(map[string]Value{"name": nil})),
newExecTest(
"Basic use statement",
`{% extends '{% block message %}{% endblock %}' %}{% use '{% block message %}Hello{% endblock %}' %}`,
expect("Hello"),
),
newExecTest(
"Extended use statement",
`{% extends '{% block message %}{% endblock %}' %}{% use '{% block message %}Hello{% endblock %}' with message as base_message %}{% block message %}{{ block('base_message') }}, World!{% endblock %}`,
expect("Hello, World!"),
),
newExecTest(
"Set statement",
`{% set val = 'a value' %}{{ val }}`,
expect("a value"),
),
newExecTest(
"Set statement with body",
`{% set var1 = 'Hello,' %}{% set var2 %}{{ var1 }} World!{% endset %}{{ var2 }}`,
expect("Hello, World!"),
),
newExecTest(
"Set statement evaluates once",
`{% set var0 = 0 %}{% set var1 = 'Hello,' %}
{% macro tester(var0, var1) %}
{% set var0 = var0 + 1 %}
{% endmacro %}
{% set var2 %}{{ _self.tester(var0, var1) }}{{ var1 }} World!{% endset %}
{{ var2 }}
{{ var0 }}
{{ var2 }}
{{ var0 }}`,
expectContains("Hello, World!\n1\n\n\nHello, World!\n1"),
),
newExecTest(
"Set statement invalid expr type",
`{% set v = 10 %}`,
expectErrorContains("unable to evaluate unsupported Expr type: *parse.TextNode"),
withNodeVisitor(func(n parse.Node) {
if sn, ok := n.(*parse.SetNode); ok {
sn.X = parse.NewTextNode("Hello", sn.X.Start())
}
}),
),
newExecTest(
"Do statement",
`{% do p.Name('Mister ') %}{{ p.Name('') }}`,
expect("Mister Meeseeks"),
withContext(map[string]Value{"p": &fakePerson{"Meeseeks"}}),
),
newExecTest(
"Filter statement",
`{% filter upper %}hello, world!{% endfilter %}`,
expect("HELLO, WORLD!"),
),
newExecTest(
"Import statement",
`{% import 'macros.twig' as mac %}{{ mac.test("hi") }}`,
expect("test: hi"),
),
newExecTest(
"From statement",
`{% from 'macros.twig' import test, def as other %}{{ other("", "HI!") }}`,
expect("HI!"),
),
newExecTest(
"Ternary if",
`{{ false ? (true ? "Hello" : "World") : "Words" }}`,
expect("Words"),
),
newExecTest(
"Hash literal",
`{{ {"test": 1}["test"] }}`,
expect("1"),
),
newExecTest(
"Another hash literal",
`{% set v = {quadruple: "to the power of four!", 0: "ew", "0": "it's not that bad"} %}ew? {{ v.0 }} {{ v.quadruple }}`,
expect("ew? it's not that bad to the power of four!"),
),
newExecTest(
"Array literal",
`{{ ["test", 1, "bar"][2] }}`,
expect("bar"),
),
newExecTest(
"Another Array literal",
`{{ ["test", 1, "bar"].1 }}`,
expect("1"),
),
newExecTest(
"Comparison with or",
`{% if item1 == "banana" or item2 == "apple" %}At least one item is correct{% else %}neither item is correct{% endif %}`,
expect("At least one item is correct"),
withContext(map[string]Value{"item1": "orange", "item2": "apple"}),
),
newExecTest(
"Non-existent map element without default",
`{{ data.A }} {{ data.NotThere }} {{ data.B }}`,
expect("Foo Bar"),
withContext(map[string]Value{"data": map[string]string{"A": "Foo", "B": "Bar"}}),
),
newExecTest(
"Non-existent map element with default",
`{{ data.A }} {{ data.NotThere|default("default value") }} {{ data.B }}`,
expect("Foo default value Bar"),
withContext(map[string]Value{"data": map[string]string{"A": "Foo", "B": "Bar"}}),
),
newExecTest(
"Accessing templateName on _self",
`Template: {{ _self.templateName }}`,
expect("Template: Template: {{ _self.templateName }}"),
),
newExecTest(
"Unsupported binary operator",
`{{ 1 + 2 }}`,
expectErrorContains("unsupported binary operator: _"),
withNodeVisitor(func(n parse.Node) {
if bn, ok := n.(*parse.BinaryExpr); ok {
bn.Op = "_"
}
}),
),
newExecTest(
"Unsupported binary operator",
`{{ 1 + 2 }}`,
expectErrorContains("unable to evaluate unsupported Expr type: *parse.TextNode"),
withNodeVisitor(func(n parse.Node) {
if bn, ok := n.(*parse.BinaryExpr); ok {
bn.Right = parse.NewTextNode("foo", bn.Right.Start())
}
}),
),
}
func joinExpected(expected []string) string {
res := ""
for i, e := range expected {
if i != 0 {
res = res + " or "
}
res = res + fmt.Sprintf("%#v", e)
}
return res
}
// expectMismatchError is an error that describes a test output that does not match what is expected.
type expectMismatchError struct {
actual string
expected []string
loose bool
}
func (err *expectMismatchError) Error() string {
if err.loose {
return fmt.Sprintf("%#v does not contain %s", err.actual, joinExpected(err.expected))
}
return fmt.Sprintf("%#v does not equal %s", err.actual, joinExpected(err.expected))
}
func newExpectFailedError(loose bool, actual string, expected ...string) error {
return &expectMismatchError{actual, expected, loose}
}
// expectErrorMismatchError is an error that describes a test that does not result in the expected error.
type expectErrorMismatchError struct {
actual error
expected []string
}
func (err *expectErrorMismatchError) Error() string {
if len(err.expected) == 0 {
if err.actual == nil {
// shouldn't happen in practice, but technically possible
return fmt.Sprint("error mismatch expected but there was no actual error and no error was expected! (bug?)")
}
return fmt.Sprintf("unexpected error %#v", err.actual.Error())
}
ex := "<nil>"
if err.actual != nil {
ex = fmt.Sprintf("%#v", err.actual.Error())
}
return fmt.Sprintf("%s is not the expected error %s", ex, joinExpected(err.expected))
}
func newExpectErrorMismatchError(actual error, expected ...string) error {
return &expectErrorMismatchError{actual, expected}
}
func expectChained(expects ...testValidator) testValidator {
return func(actual string, err error) error {
for _, expect := range expects {
if e := expect(actual, err); e != nil {
return e
}
}
return nil
}
}
func expect(expected ...string) testValidator {
return expectChained(expectNoError(), func(actual string, err error) error {
for _, exp := range expected {
if actual == exp {
return nil
}
}
return newExpectFailedError(false, actual, expected...)
})
}
func expectContains(matches string) testValidator {
return expectChained(expectNoError(), func(actual string, err error) error {
if !strings.Contains(actual, matches) {
return newExpectFailedError(true, actual, matches)
}
return nil
})
}
func expectNoError() testValidator {
return func(actual string, err error) error {
if err != nil {
return newExpectErrorMismatchError(err)
}
return nil
}
}
func expectErrorContains(expected ...string) testValidator {
return func(_ string, err error) error {
if err == nil && len(expected) == 0 {
return nil
}
actual := "<nil>"
if err != nil {
actual = err.Error()
}
for _, e := range expected {
if strings.Contains(actual, e) {
// actual error matches one of the expected values
return nil
}
}
// no match was found for actual error
return newExpectErrorMismatchError(err, expected...)
}
}
type testLoader struct {
templates map[string]Template
}
func newTestLoader(templates []Template) *testLoader {
tpls := make(map[string]Template)
for i := 0; i < len(templates); i++ {
tpl := templates[i]
tpls[tpl.Name()] = tpl
}
return &testLoader{tpls}
}
type testTemplate struct {
name string
contents string
}
func (t *testTemplate) Name() string {
return t.name
}
func (t *testTemplate) Contents() io.Reader {
return bytes.NewReader([]byte(t.contents))
}
func tpl(name, content string) *testTemplate {
return &testTemplate{name, content}
}
func (t *testLoader) Load(name string) (Template, error) {
if b, ok := t.templates[name]; ok {
return b, nil
}
return tpl(name, name), nil
}
// testVisitor is a parse.NodeVisitor to enable tests to arbitrarily modify a parsed tree.
type testVisitor struct {
// visit is called when the visitor leaves a node
visit func(parse.Node)
}
func (v *testVisitor) Enter(parse.Node) {
// only Leave is used for simplicity's sake
}
func (v *testVisitor) Leave(n parse.Node) {
if v.visit != nil {
v.visit(n)
}
}
func evaluateTest(t *testing.T, env *Env, test execTest) {
w := &bytes.Buffer{}
err := execute(test.tpl, w, test.ctx, env)
out := w.String()
if err := test.checkResult(out, err); err != nil {
t.Errorf("%s: %s", test.name, err)
}
}
func TestExec(t *testing.T) {
env := New(newTestLoader(
[]Template{
tpl("macros.twig", `
{% macro test(arg) %}test: {{ arg }}{% endmacro %}
{% macro def(val, default) %}{% if not val %}{{ default }}{% else %}{{ val }}{% endif %}{% endmacro %}
`),
},
))
env.Functions["multiply"] = func(ctx Context, args ...Value) Value {
if len(args) != 2 {
return 0
}
return CoerceNumber(args[0]) * CoerceNumber(args[1])
}
env.Filters["upper"] = func(ctx Context, val Value, args ...Value) Value {
return strings.ToUpper(CoerceString(val))
}
env.Filters["default"] = func(ctx Context, val Value, args ...Value) Value {
var d Value
if len(args) == 0 {
d = nil
} else {
d = args[0]
}
if CoerceString(val) == "" {
return d
}
return val
}
tv := &testVisitor{}
env.Visitors = append(env.Visitors, tv)
for _, test := range tests {
tv.visit = test.visitNode
evaluateTest(t, env, test)
}
}
type fakePerson struct {
name string
}
func (p *fakePerson) Name(prefix string) string {
p.name = prefix + p.name
return p.name
}