zotero/test/tests/templatesTest.js
Tom Najdek c39f16891f
Some checks are pending
CI / Test (shard 1) (push) Waiting to run
CI / Test (shard 2) (push) Waiting to run
CI / Test (shard 3) (push) Waiting to run
CI / Test (shard 4) (push) Waiting to run
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
Extend the template engine and add template validation (#5989)
- Fix multiple potential scenarios causing a template engine crash
 - Add support for specifying string literals in the template engine
 - Validate `if/else/elseif` order and matching clause closures
 - Validate to ensure every `{{` is properly closed with a matching `}}`
 - When a template is invalid, display a warning, do not offer batch-renaming tools, do not update synced setting
 - When a template is invalid, prompt the user to fix or reset the template when

Closes #5965
2026-07-29 13:30:12 -04:00

545 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

var {
generateHTMLFromTemplate,
parseTemplateBrackets,
isTemplateValid,
} = ChromeUtils.importESModule("chrome://zotero/content/modules/templates.mjs");
describe("Templates", function () {
describe("#generateHTMLFromTemplate()", function () {
it("should support variables with attributes", function () {
var vars = {
v1: '1',
v2: pars => `${pars.a1 ?? ''}${pars.a2 ?? ''}${pars.a3 ?? ''}`,
v3: () => '',
v5: () => 'something',
ar1: [],
ar2: [1, 2]
};
var template = `{{ v1}}{{v2 a1= "1" a2 =' 2' a3 = "3 "}}{{v3}}{{v4}}{{if ar1}}ar1{{endif}}{{if ar2}}{{ar2}}{{endif}}{{if v5}}yes{{endif}}{{if v3}}no1{{endif}}{{if v2}}{{v2}}{{endif}}`;
var html = generateHTMLFromTemplate(template, vars);
assert.equal(html, '11 23 1,2yes');
});
it("should support empty string as attribute value and correctly render returned false-ish values", function () {
const vars = {
length: ({ string }) => string.length.toString(),
};
const template = `"" has a length of {{ length string="" }} and "hello" has a length of {{ length string="hello" }}`;
const out = generateHTMLFromTemplate(template, vars);
assert.equal(out, '"" has a length of 0 and "hello" has a length of 5');
});
it("should parse attributes using the shared string-literal semantics", function () {
const vars = { echo: attrs => JSON.stringify(attrs) };
// Both quote styles, an empty value, and a hyphenated (camelCased) name
assert.equal(
generateHTMLFromTemplate('{{ echo a="x" b=\'y\' c="" my-attr="z" }}', vars),
JSON.stringify({ a: 'x', b: 'y', c: '', myAttr: 'z' })
);
// A backslash is an ordinary character; a `"` always closes the value
assert.equal(
generateHTMLFromTemplate('{{ echo a="p\\q" }}', vars),
JSON.stringify({ a: 'p\\q' })
);
// A value may contain the opposite quote by delimiting with the other one
assert.equal(
generateHTMLFromTemplate(`{{ echo a='"' b="it's" }}`, vars),
JSON.stringify({ a: '"', b: "it's" })
);
// A newline inside a value is allowed
assert.equal(
generateHTMLFromTemplate('{{ echo a="p\nq" }}', vars),
JSON.stringify({ a: 'p\nq' })
);
});
it("should support functions in comparison statements", function () {
const vars = {
sum: ({ a, b }) => (parseInt(a) + parseInt(b)).toString(),
fooBar: ({ isFoo }) => (isFoo === 'true' ? 'foo' : 'bar'),
false: 'false',
twoWords: 'two words',
onlyOne: 'actually == 1'
};
const template = `{{if {{ sum a="1" b="2" }} == "3"}}1 + 2 = {{sum a="1" b="2"}}{{else}}no speak math{{endif}}`;
const out = generateHTMLFromTemplate(template, vars);
assert.equal(out, '1 + 2 = 3');
const template2 = '{{if false != "false"}}no{{elseif false == "false"}}yes{{else}}no{{endif}}';
const out2 = generateHTMLFromTemplate(template2, vars);
assert.equal(out2, 'yes');
const template3 = '{{ if twoWords == "two words" }}yes{{else}}no{{endif}}';
const out3 = generateHTMLFromTemplate(template3, vars);
assert.equal(out3, 'yes');
const template4 = '{{ if onlyOne == \'actually == 1\' }}yes{{else}}no{{endif}}';
const out4 = generateHTMLFromTemplate(template4, vars);
assert.equal(out4, 'yes');
const template5 = '{{ if "3" == {{ sum a="1" b="2" }} }}yes{{else}}no{{endif}}';
const out5 = generateHTMLFromTemplate(template5, vars);
assert.equal(out5, 'yes');
const template6 = '{{ if {{ sum a="1" b="2" }} }}yes{{else}}no{{endif}}';
const out6 = generateHTMLFromTemplate(template6, vars);
assert.equal(out6, 'yes');
const template7 = '{{ if {{ twoWords }} }}yes{{else}}no{{endif}}';
const out7 = generateHTMLFromTemplate(template7, vars);
assert.equal(out7, 'yes');
const template8 = '{{ if twoWords }}yes{{else}}no{{endif}}';
const out8 = generateHTMLFromTemplate(template8, vars);
assert.equal(out8, 'yes');
const template9 = '{{ if missing }}no{{else}}yes{{endif}}';
const out9 = generateHTMLFromTemplate(template9, vars);
assert.equal(out9, 'yes');
const template10 = '{{ if {{ missing foo="bar" }} }}no{{else}}yes{{endif}}';
const out10 = generateHTMLFromTemplate(template10, vars);
assert.equal(out10, 'yes');
const template11 = '{{ if {{ missing foo="bar" }} == "" }}yes{{else}}no{{endif}}';
const out11 = generateHTMLFromTemplate(template11, vars);
assert.equal(out11, 'yes');
const template12 = '{{ if fooBar == "bar" }}yes{{else}}no{{endif}}';
const out12 = generateHTMLFromTemplate(template12, vars);
assert.equal(out12, 'yes');
const template13 = '{{ if {{ fooBar }} == "bar" }}yes{{else}}no{{endif}}';
const out13 = generateHTMLFromTemplate(template13, vars);
assert.equal(out13, 'yes');
const template14 = `{{if {{ sum a="1" b="2" }}=="3"}}1 + 2 = {{sum a="1" b="2"}}{{else}}no{{endif}}`;
const out14 = generateHTMLFromTemplate(template14, vars);
assert.equal(out14, '1 + 2 = 3');
const template15 = `{{if "two words"==twoWords}}yes{{else}}no{{endif}}`;
const out15 = generateHTMLFromTemplate(template15, vars);
assert.equal(out15, 'yes');
});
it("should support relational operators", function () {
const vars = {
sum: ({ a, b }) => (parseInt(a) + parseInt(b)).toString(),
v1: '1',
v2: 'foo',
v3: '100',
v4: '99',
π: '3.14',
};
const template1 = `{{if v1 > π}}more than π{{elseif v1 <= π}}less or equal to π{{endif}}`;
const out1 = generateHTMLFromTemplate(template1, vars);
assert.equal(out1, 'less or equal to π');
const template2 = `{{if {{ sum a="2" b="3" }} > π}}more than π{{else}}less or equal to π{{endif}}`;
const out2 = generateHTMLFromTemplate(template2, vars);
assert.equal(out2, 'more than π');
const template3 = `{{if 3.14 >= π}}more than or equal to π{{else}}less than π{{endif}}`;
const out3 = generateHTMLFromTemplate(template3, vars);
assert.equal(out3, 'more than or equal to π');
const template4 = `{{if v3 > v4}}100 is more than 99{{else}}string "100" would be sorted before "99"{{endif}}`;
const out4 = generateHTMLFromTemplate(template4, vars);
assert.equal(out4, '100 is more than 99');
// This is undocumented and unsupported behavior, but comparing strings should work
const template5 = `{{if "test" > v2}}"test" > "foo"{{else}}no{{endif}}`;
const out5 = generateHTMLFromTemplate(template5, vars);
assert.equal(out5, '"test" > "foo"');
const template6 = `{{if "bar" < v2 }}"bar" < "foo"{{else}}no{{endif}}`;
const out6 = generateHTMLFromTemplate(template6, vars);
assert.equal(out6, '"bar" < "foo"');
});
it("should compare as strings when a bare number is compared with a non-numeric value", function () {
const vars = {
edition: '2nd ed.',
year: '2020',
};
const template1 = '{{if edition == 2}}second{{else}}other{{endif}}';
const out1 = generateHTMLFromTemplate(template1, vars);
assert.equal(out1, 'other');
const template2 = '{{if edition != 2}}other{{else}}second{{endif}}';
const out2 = generateHTMLFromTemplate(template2, vars);
assert.equal(out2, 'other');
const template3 = '{{if 2 == edition}}second{{else}}other{{endif}}';
const out3 = generateHTMLFromTemplate(template3, vars);
assert.equal(out3, 'other');
const template4 = '{{if edition < 5}}less{{else}}more{{endif}}';
const out4 = generateHTMLFromTemplate(template4, vars);
assert.equal(out4, 'less');
// The same comparison still evaluates numerically when the value is numeric
const template5 = '{{if year == 2020}}yes{{else}}no{{endif}}';
const out5 = generateHTMLFromTemplate(template5, vars);
assert.equal(out5, 'yes');
});
it("should accept hyphen-case variables and attributes", function () {
const vars = {
fooBar: ({ isFoo }) => (isFoo === 'true' ? 'foo' : 'bar'),
};
const template = '{{ foo-bar is-foo="true" }}{{ if {{ foo-bar is-foo="false" }} == "bar" }}{{ foo-bar is-foo="false" }}{{ endif }}';
const out = generateHTMLFromTemplate(template, vars);
assert.equal(out, 'foobar');
});
it("should not throw on an unbalanced {{endif}}", function () {
const vars = {
v1: '1',
};
// An {{endif}} without a matching {{if}} shouldn't pop the base level and crash
const template = 'foo{{endif}}bar{{if v1 == "1"}}baz{{endif}}';
const out = generateHTMLFromTemplate(template, vars);
assert.equal(out, 'foobarbaz');
});
it("should work with a condition in the middle", function () {
const vars = {
v1: '1',
};
const template = 'test {{ if v1 == "1" }}yes{{ else }}no{{ endif }} foobar';
const out = generateHTMLFromTemplate(template, vars);
assert.equal(out, 'test yes foobar');
});
it("missing identifiers are evaluted as empty string", function () {
const vars = {
foo: 'foo',
};
const template = '{{bar}}{{ if foo == "" }}no{{elseif foo}}{{foo}}{{else}}no{{endif}}';
const out = generateHTMLFromTemplate(template, vars);
assert.equal(out, 'foo');
const template2 = 'test: {{ if bar == "" }}yes{{else}}no{{endif}}';
const out2 = generateHTMLFromTemplate(template2, vars);
assert.equal(out2, 'test: yes');
});
it("should preserve whitespace outside of brackets", function () {
const template = ' starts }} with {{ whitespace {"test"} == \'foobar\' ';
const out = generateHTMLFromTemplate(template, {});
assert.equal(out, template);
const vars = {
space: ' ',
spaceFn: () => ' ',
};
const whitespace = ' {{if spaceFn}}{{else}} {{endif}}{{space}} {{space-fn}}';
const out2 = generateHTMLFromTemplate(whitespace, vars);
assert.equal(out2, ' ');
});
it("should accept array values in logic statements", function () {
let someTags = ['foo', 'bar'];
const vars = {
tags: ({ join }) => (join ? someTags.join(join) : someTags),
};
const template = '{{ if tags }}#{{ tags join=" #" }}{{else}}no tags{{endif}}';
const out = generateHTMLFromTemplate(template, vars);
assert.equal(out, '#foo #bar');
someTags = [];
const out2 = generateHTMLFromTemplate(template, vars);
assert.equal(out2, 'no tags');
});
it("should throw if function returns anything else than a string (or an array which is always joined into string)", function () {
const vars = {
number: () => 1,
logic: () => true,
array: () => [],
fn: () => 1,
};
assert.throws(() => generateHTMLFromTemplate('{{ number }}', vars), /Identifier "number" does not evaluate to a string/);
assert.throws(() => generateHTMLFromTemplate('{{ logic }}', vars), /Identifier "logic" does not evaluate to a string/);
assert.throws(() => generateHTMLFromTemplate('{{ if fn }}no{{endif}}', vars), /Identifier "fn" does not evaluate to a string/);
assert.throws(() => generateHTMLFromTemplate('{{ if {{ fn foo="bar" }} }}no{{endif}}', vars), /Identifier "fn" does not evaluate to a string/);
});
it("should support nested 'if' statements", function () {
var vars = {
v1: '1',
v2: 'H',
};
var template = `{{if v1 == '1'}}yes1{{if x}}no{{elseif v2 == "h" }}yes2{{endif}}{{elseif v2 == "2"}}no{{else}}no{{endif}} {{if v2 == "1"}}not{{elseif x}}not{{else}}yes3{{ endif}}`;
var html = generateHTMLFromTemplate(template, vars);
assert.equal(html, 'yes1yes2 yes3');
});
it("should render a string literal", function () {
assert.equal(generateHTMLFromTemplate('{{ "literal" }}', {}), 'literal');
assert.equal(generateHTMLFromTemplate("{{ 'literal' }}", {}), 'literal');
});
it("should render brace characters via string literals", function () {
assert.equal(generateHTMLFromTemplate('{{ "{" }}', {}), '{');
assert.equal(generateHTMLFromTemplate('{{ "{{" }}', {}), '{{');
assert.equal(generateHTMLFromTemplate('{{ "}}" }}', {}), '}}');
assert.equal(generateHTMLFromTemplate('{{ "}" }}', {}), '}');
});
it("should render string literals alongside text and variables", function () {
const vars = { v1: 'X' };
assert.equal(generateHTMLFromTemplate('a{{ "{{" }}{{v1}}{{ "}}" }}c', vars), 'a{{X}}c');
});
it("should render a string literal that spans multiple lines", function () {
assert.equal(generateHTMLFromTemplate('{{ "a\nb" }}', {}), 'a\nb');
assert.equal(generateHTMLFromTemplate("{{ 'a\nb' }}", {}), 'a\nb');
});
it("should not treat a quoted keyword as a control statement", function () {
assert.equal(generateHTMLFromTemplate('{{ "if" }}', {}), 'if');
assert.equal(generateHTMLFromTemplate('{{ "endif" }}', {}), 'endif');
});
it("should not treat a malformed quoted statement as a string literal", function () {
assert.equal(generateHTMLFromTemplate('{{ "x" y="z" }}', {}), '');
assert.equal(generateHTMLFromTemplate('{{ "a" "b" }}', {}), '');
// A `"` inside a value closes the string, so an embedded quote makes the statement malformed
assert.equal(generateHTMLFromTemplate('{{ "a\\"b" }}', {}), '');
});
it("should treat an empty or bare string-literal condition as false, forcing {{else}}", function () {
assert.equal(generateHTMLFromTemplate('{{if}}Y{{else}}N{{endif}}', {}), 'N');
assert.equal(generateHTMLFromTemplate('{{if "foo"}}Y{{else}}N{{endif}}', {}), 'N');
});
it("should treat any whitespace after a control keyword as a separator", function () {
// split(' ') only splits on an ASCII space; a tab/NBSP/newline after `if` must still
// parse as the `if` keyword rather than a mangled `if\ttitle` identifier
for (let ws of ['\t', ' ', '\n']) {
let tmpl = `{{if${ws}v}}KEEP{{endif}}`;
assert.equal(generateHTMLFromTemplate(tmpl, { v: '1' }), 'KEEP', JSON.stringify(ws));
assert.equal(generateHTMLFromTemplate(tmpl, {}), '', JSON.stringify(ws));
}
});
it("should treat an empty {{}} comparison operand as an empty value instead of throwing", function () {
const vars = { color: 'red', empty: '' };
// Empty operand on either side of any comparator must not crash the engine
assert.equal(generateHTMLFromTemplate('{{if {{}} == color}}Y{{else}}N{{endif}}', vars), 'N');
assert.equal(generateHTMLFromTemplate('{{if color == {{}}}}Y{{else}}N{{endif}}', vars), 'N');
assert.equal(generateHTMLFromTemplate('{{if {{}} != color}}Y{{else}}N{{endif}}', vars), 'Y');
// An empty operand compares equal to an empty variable
assert.equal(generateHTMLFromTemplate('{{if {{}} == empty}}Y{{else}}N{{endif}}', vars), 'Y');
});
it("should not treat a }} or comparator inside a nested operand's quoted argument as structure", function () {
const vars = { echo: attrs => attrs.x ?? '' };
// The `}}` and `==` inside x="…" must not be read as the operand end or the comparator
assert.equal(
generateHTMLFromTemplate('{{if {{ echo x="}} ==" }} == "}} =="}}Y{{else}}N{{endif}}', vars),
'Y'
);
// The same nested call compared with a non-matching literal takes the else branch
assert.equal(
generateHTMLFromTemplate('{{if {{ echo x="}} ==" }} == "other"}}Y{{else}}N{{endif}}', vars),
'N'
);
});
it("should evaluate a nested {{ }} string literal to its contents, like at the top level", function () {
// `{{ "x" }}` renders `x` at the top level, so as a nested operand it must evaluate to `x`
// (not be treated as a missing identifier), so the comparison behaves consistently
assert.equal(generateHTMLFromTemplate('{{if {{ "x" }} == "x"}}Y{{else}}N{{endif}}', {}), 'Y');
assert.equal(generateHTMLFromTemplate("{{if {{ 'x' }} == 'x'}}Y{{else}}N{{endif}}", {}), 'Y');
// ...on either side of the comparator
assert.equal(generateHTMLFromTemplate('{{if "x" == {{ "x" }}}}Y{{else}}N{{endif}}', {}), 'Y');
// A nested literal used directly as a condition is evaluated too: non-empty is truthy,
// empty is falsy (a bare, unbracketed literal remains an identifier ref -- see the
// "bare string-literal condition as false" test above)
assert.equal(generateHTMLFromTemplate('{{if {{ "foo" }}}}Y{{else}}N{{endif}}', {}), 'Y');
assert.equal(generateHTMLFromTemplate('{{if {{ "" }}}}Y{{else}}N{{endif}}', {}), 'N');
});
});
describe("#parseTemplateBrackets()", function () {
it("should split text and bracketed statements", function () {
assert.deepEqual(parseTemplateBrackets('a{{b}}c'), ['a', '{{b}}', 'c']);
});
it("should return a single part for text without brackets", function () {
assert.deepEqual(parseTemplateBrackets('abc'), ['abc']);
});
it("should keep nested brackets within a single outer part", function () {
assert.deepEqual(
parseTemplateBrackets('{{ if {{ x }} }}'),
['', '{{ if {{ x }} }}']
);
});
it("should treat braces inside string literals as literal characters", function () {
assert.deepEqual(parseTemplateBrackets('{{ "{{" }}'), ['', '{{ "{{" }}']);
assert.deepEqual(parseTemplateBrackets('{{ "}}" }}'), ['', '{{ "}}" }}']);
});
it("should not close a statement early when a nested {{}} is adjacent to the outer }}", function () {
// The trailing `}}}}` must be read as the inner `}}` then the outer `}}`, not overlapping
assert.deepEqual(
parseTemplateBrackets('{{if color == {{}}}}'),
['', '{{if color == {{}}}}']
);
assert.deepEqual(
parseTemplateBrackets('{{if {{}}}}X{{endif}}'),
['', '{{if {{}}}}', 'X', '{{endif}}']
);
});
it("should treat `}}` inside a string literal as an ordinary character, not a delimiter", function () {
assert.deepEqual(
parseTemplateBrackets('{{ "a}}b" }}X'),
['', '{{ "a}}b" }}', 'X']
);
assert.deepEqual(
parseTemplateBrackets('{{ title x="}}" }}TAIL'),
['', '{{ title x="}}" }}', 'TAIL']
);
});
it("should not let a trailing backslash before a closing quote swallow a later statement", function () {
// A literal-backslash suffix followed by another quoted statement must stay two
// statements; the `\` before the closing quote must not pair with the later quote
assert.deepEqual(
parseTemplateBrackets('{{ title suffix="\\" }}{{ title prefix="x" }}'),
['', '{{ title suffix="\\" }}', '', '{{ title prefix="x" }}']
);
assert.isTrue(isTemplateValid('{{ title suffix="\\" }}{{ title prefix="x" }}'));
});
});
describe("#isTemplateValid()", function () {
it("should return false for a template with mismatched if/endif", function () {
// The stray {{endif}} that used to crash the engine (see #generateHTMLFromTemplate() tests)
const template = 'foo{{endif}}bar{{if v1 == "1"}}baz{{endif}}';
assert.isFalse(isTemplateValid(template));
});
it("should return false for an {{if}} without a matching {{endif}}", function () {
assert.isFalse(isTemplateValid('{{if v1 == "1"}}baz'));
});
it("should return true for balanced if/endif", function () {
assert.isTrue(isTemplateValid('foo{{if v1 == "1"}}baz{{endif}}'));
});
it("should treat any whitespace after a control keyword as a separator", function () {
// Must agree with the engine: a tab/newline after `if` still parses as the `if` keyword,
// so the block is balanced rather than being read as a mangled `if\ttitle` identifier
for (let ws of ['\t', ' ', '\n']) {
assert.isTrue(isTemplateValid(`{{if${ws}v}}KEEP{{endif}}`), JSON.stringify(ws));
}
});
it("should return false for a non-string template", function () {
assert.isFalse(isTemplateValid(null));
assert.isFalse(isTemplateValid(undefined));
assert.isFalse(isTemplateValid(42));
});
it("should return false for an unmatched opening {{", function () {
assert.isFalse(isTemplateValid('{{ title'));
assert.isFalse(isTemplateValid('foo {{ title }} bar {{ baz'));
});
it("should return false for a stray, non-literal }}", function () {
assert.isFalse(isTemplateValid('title }}'));
assert.isFalse(isTemplateValid('foo }} bar'));
});
it("should treat braces inside string literals as valid", function () {
assert.isTrue(isTemplateValid('{{ "{{" }}'));
assert.isTrue(isTemplateValid('{{ "}}" }}'));
assert.isTrue(isTemplateValid('a{{ "{{" }}b{{ "}}" }}c'));
});
it("should return false for a malformed quoted statement", function () {
assert.isFalse(isTemplateValid('{{ "x" y="z" }}'));
assert.isFalse(isTemplateValid('{{ "a" "b" }}'));
});
it("should return false for a statement with an unterminated quote", function () {
assert.isFalse(isTemplateValid('{{ title truncate="50 }}'));
assert.isFalse(isTemplateValid("{{ title truncate='50 }}"));
assert.isFalse(isTemplateValid('{{if title == "x }}a{{endif}}'));
});
it("should reject an unclosed {{ that a nested }} makes look closed", function () {
// parseTemplateBrackets folds an unclosed outer `{{` and its nested `}}` into one part
// that ends in `}}`; without a balance check the engine would keep it and silently
// drop `year` at render time
assert.isFalse(isTemplateValid('{{ firstCreator {{ year }}'));
assert.isFalse(isTemplateValid('foo {{ bar {{ baz }}'));
});
it("should accept a balanced nested {{ }} operand", function () {
assert.isTrue(isTemplateValid('{{if {{ authorsCount }} == "2"}}a{{endif}}'));
});
it("should return false when an unterminated quote swallows a following statement", function () {
// The unterminated quote makes the tokenizer fold `{{ year }}` into one part, which
// would otherwise validate as a lone statement and silently drop `year` at render time
assert.isFalse(isTemplateValid('{{ title x="a }}{{ year }}'));
});
it("should return true for a well-formed string literal statement", function () {
assert.isTrue(isTemplateValid('{{ "literal" }}'));
assert.isTrue(isTemplateValid('{{ "a}}b" }}'));
});
it("should accept a string literal that spans multiple lines", function () {
assert.isTrue(isTemplateValid('{{ "a\nb" }}'));
assert.isTrue(isTemplateValid("{{ 'a\nb' }}"));
});
it("should reject an attribute value with an unescaped closing quote", function () {
// Backslashes are literal, so a `"` always closes the value; `x="a\""` leaves a stray
// quote that never closes the statement
assert.isFalse(isTemplateValid('{{ title x="a\\"" }}TAIL'));
});
it("should allow single brace characters in text", function () {
assert.isTrue(isTemplateValid('a { b } c'));
});
it("should return false for an {{elseif}} after an {{else}}", function () {
// Renders two branches in the engine (e.g. "ac"), so reject it
assert.isFalse(isTemplateValid('{{if title}}a{{else}}b{{elseif year}}c{{endif}}'));
});
it("should return false for a second {{else}} in the same block", function () {
assert.isFalse(isTemplateValid('{{if title}}a{{else}}b{{else}}c{{endif}}'));
});
it("should accept an {{if}} with an empty condition", function () {
// An empty condition renders as false (forcing the {{else}} branch), which the engine
// has always handled; keep it valid so existing templates don't fall back to the default
assert.isTrue(isTemplateValid('{{if}}a{{endif}}'));
assert.isTrue(isTemplateValid('{{if }}a{{endif}}'));
assert.isTrue(isTemplateValid('{{if}}a{{else}}b{{endif}}'));
});
it("should accept an {{elseif}} with an empty condition", function () {
assert.isTrue(isTemplateValid('{{if title}}a{{elseif}}b{{endif}}'));
});
it("should return true for a valid if/elseif/else chain", function () {
assert.isTrue(isTemplateValid('{{if a}}1{{elseif b}}2{{else}}3{{endif}}'));
assert.isTrue(isTemplateValid('{{if a}}1{{if b}}2{{else}}3{{endif}}{{else}}4{{endif}}'));
});
});
});