From 88c9e96392dfeeb61755d5e633c3346c5ed26da8 Mon Sep 17 00:00:00 2001 From: harttle Date: Sun, 24 Feb 2019 21:22:19 +0800 Subject: [PATCH] feat: nil/null/empty/blank literals, resolves #102 --- src/drop/blank-drop.ts | 12 ++++++ src/drop/drop.ts | 2 + src/drop/empty-drop.ts | 27 +++++++++++++ src/drop/icomparable.ts | 13 ++++++ src/drop/idrop.ts | 10 +++++ src/drop/null-drop.ts | 26 ++++++++++++ src/render/syntax.ts | 74 ++++++++++++++++++++++++++-------- src/scope/icontext.ts | 4 -- src/scope/scope.ts | 18 ++++++--- src/util/underscore.ts | 12 +++--- test/e2e/drop.ts | 21 ++++++++++ test/e2e/parse-and-render.ts | 5 +++ test/unit/builtin/tags/case.ts | 22 +++++----- test/unit/builtin/tags/for.ts | 4 +- test/unit/drop/blank-drop.ts | 52 ++++++++++++++++++++++++ test/unit/drop/empty-drop.ts | 52 ++++++++++++++++++++++++ test/unit/drop/null-drop.ts | 36 +++++++++++++++++ test/unit/render/syntax.ts | 10 ++++- test/unit/scope/scope.ts | 7 ++-- 19 files changed, 355 insertions(+), 52 deletions(-) create mode 100644 src/drop/blank-drop.ts create mode 100644 src/drop/drop.ts create mode 100644 src/drop/empty-drop.ts create mode 100644 src/drop/icomparable.ts create mode 100644 src/drop/idrop.ts create mode 100644 src/drop/null-drop.ts delete mode 100644 src/scope/icontext.ts create mode 100644 test/e2e/drop.ts create mode 100644 test/unit/drop/blank-drop.ts create mode 100644 test/unit/drop/empty-drop.ts create mode 100644 test/unit/drop/null-drop.ts diff --git a/src/drop/blank-drop.ts b/src/drop/blank-drop.ts new file mode 100644 index 0000000000..ceb3a8e476 --- /dev/null +++ b/src/drop/blank-drop.ts @@ -0,0 +1,12 @@ +import { isNil, isString } from 'src/util/underscore' +import { isDrop } from 'src/drop/idrop' +import { EmptyDrop } from 'src/drop/empty-drop' + +export class BlankDrop extends EmptyDrop { + equals (value: any) { + if (value === false) return true + if (isNil(isDrop(value) ? value.value() : value)) return true + if (isString(value)) return /^\s*$/.test(value) + return super.equals(value) + } +} diff --git a/src/drop/drop.ts b/src/drop/drop.ts new file mode 100644 index 0000000000..a402063fb9 --- /dev/null +++ b/src/drop/drop.ts @@ -0,0 +1,2 @@ +export abstract class Drop { +} diff --git a/src/drop/empty-drop.ts b/src/drop/empty-drop.ts new file mode 100644 index 0000000000..425bd8c025 --- /dev/null +++ b/src/drop/empty-drop.ts @@ -0,0 +1,27 @@ +import { Drop } from './drop' +import { IComparable } from './icomparable' +import { isObject, isString, isArray } from 'src/util/underscore' +import { IDrop } from 'src/drop/idrop' + +export class EmptyDrop extends Drop implements IDrop, IComparable { + equals (value: any) { + if (isString(value) || isArray(value)) return value.length === 0 + if (isObject(value)) return Object.keys(value).length === 0 + return false + } + gt () { + return false + } + geq () { + return false + } + lt () { + return false + } + leq () { + return false + } + value () { + return '' + } +} diff --git a/src/drop/icomparable.ts b/src/drop/icomparable.ts new file mode 100644 index 0000000000..0ce0b1c60b --- /dev/null +++ b/src/drop/icomparable.ts @@ -0,0 +1,13 @@ +import { isFunction } from 'src/util/underscore' + +export interface IComparable { + equals: (rhs: any) => boolean + gt: (rhs: any) => boolean + geq: (rhs: any) => boolean + lt: (rhs: any) => boolean + leq: (rhs: any) => boolean +} + +export function isComparable (arg: any): arg is IComparable { + return arg && isFunction(arg.equals) +} diff --git a/src/drop/idrop.ts b/src/drop/idrop.ts new file mode 100644 index 0000000000..c28d295889 --- /dev/null +++ b/src/drop/idrop.ts @@ -0,0 +1,10 @@ +import { Drop } from './drop' +import { isFunction } from 'src/util/underscore' + +export interface IDrop { + value(): any +} + +export function isDrop (value: any): value is IDrop { + return value instanceof Drop && isFunction((value as any).value) +} diff --git a/src/drop/null-drop.ts b/src/drop/null-drop.ts new file mode 100644 index 0000000000..2d92770dbb --- /dev/null +++ b/src/drop/null-drop.ts @@ -0,0 +1,26 @@ +import { Drop } from './drop' +import { IComparable } from './icomparable' +import { isNil } from 'src/util/underscore' +import { IDrop, isDrop } from 'src/drop/idrop' +import { BlankDrop } from 'src/drop/blank-drop' + +export class NullDrop extends Drop implements IDrop, IComparable { + equals (value: any) { + return isNil(isDrop(value) ? value.value() : value) || value instanceof BlankDrop + } + gt () { + return false + } + geq () { + return false + } + lt () { + return false + } + leq () { + return false + } + value () { + return null + } +} diff --git a/src/render/syntax.ts b/src/render/syntax.ts index bc2fdcfa33..027ac5995f 100644 --- a/src/render/syntax.ts +++ b/src/render/syntax.ts @@ -2,14 +2,43 @@ import * as lexical from '../parser/lexical' import assert from '../util/assert' import Scope from 'src/scope/scope' import { range, last } from 'src/util/underscore' +import { isComparable } from 'src/drop/icomparable' +import { NullDrop } from 'src/drop/null-drop' +import { EmptyDrop } from 'src/drop/empty-drop' +import { BlankDrop } from 'src/drop/blank-drop' +import { isDrop } from 'src/drop/idrop' -const operators = { - '==': (l: any, r: any) => l === r, - '!=': (l: any, r: any) => l !== r, - '>': (l: any, r: any) => l !== null && r !== null && l > r, - '<': (l: any, r: any) => l !== null && r !== null && l < r, - '>=': (l: any, r: any) => l !== null && r !== null && l >= r, - '<=': (l: any, r: any) => l !== null && r !== null && l <= r, +const binaryOperators: {[key: string]: (lhs: any, rhs: any) => boolean} = { + '==': (l: any, r: any) => { + if (isComparable(l)) return l.equals(r) + if (isComparable(r)) return r.equals(l) + return l === r + }, + '!=': (l: any, r: any) => { + if (isComparable(l)) return !l.equals(r) + if (isComparable(r)) return !r.equals(l) + return l !== r + }, + '>': (l: any, r: any) => { + if (isComparable(l)) return l.gt(r) + if (isComparable(r)) return r.lt(l) + return l > r + }, + '<': (l: any, r: any) => { + if (isComparable(l)) return l.lt(r) + if (isComparable(r)) return r.gt(l) + return l < r + }, + '>=': (l: any, r: any) => { + if (isComparable(l)) return l.geq(r) + if (isComparable(r)) return r.leq(l) + return l >= r + }, + '<=': (l: any, r: any) => { + if (isComparable(l)) return l.leq(r) + if (isComparable(r)) return r.geq(l) + return l <= r + }, 'contains': (l: any, r: any) => { if (!l) return false if (typeof l.indexOf !== 'function') return false @@ -19,41 +48,54 @@ const operators = { 'or': (l: any, r: any) => isTruthy(l) || isTruthy(r) } -export function evalExp (exp: string, scope: Scope): any { - assert(scope, 'unable to evalExp: scope undefined') +export function parseExp (exp: string, scope: Scope): any { + assert(scope, 'unable to parseExp: scope undefined') const operatorREs = lexical.operators let match for (let i = 0; i < operatorREs.length; i++) { const operatorRE = operatorREs[i] const expRE = new RegExp(`^(${lexical.quoteBalanced.source})(${operatorRE.source})(${lexical.quoteBalanced.source})$`) if ((match = exp.match(expRE))) { - const l = evalExp(match[1], scope) - const op = operators[match[2].trim()] - const r = evalExp(match[3], scope) + const l = parseExp(match[1], scope) + const op = binaryOperators[match[2].trim()] + const r = parseExp(match[3], scope) return op(l, r) } } if ((match = exp.match(lexical.rangeLine))) { - const low = evalValue(match[1], scope) - const high = evalValue(match[2], scope) + const low = parseValue(match[1], scope) + const high = parseValue(match[2], scope) return range(low, high + 1) } - return evalValue(exp, scope) + return parseValue(exp, scope) +} + +export function evalExp (str: string, scope: Scope): any { + const value = parseExp(str, scope) + return isDrop(value) ? value.value() : value } -export function evalValue (str: string, scope: Scope) { +function parseValue (str: string, scope: Scope): any { if (!str) return null str = str.trim() if (str === 'true') return true if (str === 'false') return false + if (str === 'nil' || str === 'null') return new NullDrop() + if (str === 'empty') return new EmptyDrop() + if (str === 'blank') return new BlankDrop() if (!isNaN(Number(str))) return Number(str) if ((str[0] === '"' || str[0] === "'") && str[0] === last(str)) return str.slice(1, -1) return scope.get(str) } +export function evalValue (str: string, scope: Scope): any { + const value = parseValue(str, scope) + return isDrop(value) ? value.value() : value +} + export function isTruthy (val: any): boolean { return !isFalsy(val) } diff --git a/src/scope/icontext.ts b/src/scope/icontext.ts deleted file mode 100644 index 62a791325d..0000000000 --- a/src/scope/icontext.ts +++ /dev/null @@ -1,4 +0,0 @@ -export default interface IContext { - [key: string]: any; - liquid_method_missing?: (key: string) => any; // eslint-disable-line -} diff --git a/src/scope/scope.ts b/src/scope/scope.ts index 03a7f0ccd4..f225beb403 100644 --- a/src/scope/scope.ts +++ b/src/scope/scope.ts @@ -3,11 +3,17 @@ import { __assign } from 'tslib' import assert from '../util/assert' import { NormalizedFullOptions, applyDefault } from '../liquid-options' import BlockMode from './block-mode' -import IContext from './icontext' + +export type Context = { + [key: string]: any + liquid_method_missing?: (key: string) => any // eslint-disable-line + to_liquid?: () => any // eslint-disable-line + toLiquid?: () => any // eslint-disable-line +} export default class Scope { opts: NormalizedFullOptions - contexts: Array + contexts: Array blocks: object = {} groups: {[key: string]: number} = {} blockMode: BlockMode = BlockMode.OUTPUT @@ -67,10 +73,10 @@ export default class Scope { } return null } - private readProperty (obj: IContext, key: string) { + private readProperty (obj: Context, key: string) { let val if (_.isNil(obj)) { - val = undefined + val = obj } else { obj = toLiquid(obj) val = key === 'size' ? readSize(obj) : obj[key] @@ -144,7 +150,7 @@ export default class Scope { } } -function toLiquid (obj: IContext) { +function toLiquid (obj: Context) { if (_.isFunction(obj.to_liquid)) { return obj.to_liquid() } @@ -154,7 +160,7 @@ function toLiquid (obj: IContext) { return obj } -function readSize (obj: IContext) { +function readSize (obj: Context) { if (!_.isNil(obj.size)) return obj.size if (_.isArray(obj) || _.isString(obj)) return obj.length return obj.size diff --git a/src/util/underscore.ts b/src/util/underscore.ts index f1d841a695..d302bbe741 100644 --- a/src/util/underscore.ts +++ b/src/util/underscore.ts @@ -6,11 +6,11 @@ const arrToStr = Array.prototype.toString * @param {any} value The value to check. * @return {Boolean} Returns true if value is a string, else false. */ -export function isString (value: any) { +export function isString (value: any): value is string { return toStr.call(value) === '[object String]' } -export function isFunction (value: any) { +export function isFunction (value: any): value is Function { return typeof value === 'function' } @@ -37,7 +37,7 @@ export function stringify (value: any): string { } function defaultToString (value: any): string { - const cache: string[] = [] + const cache: any[] = [] return JSON.stringify(value, (key, value) => { if (isObject(value)) { if (cache.indexOf(value) !== -1) { @@ -57,12 +57,12 @@ export function isNil (value: any): boolean { return value === null || value === undefined } -export function isArray (value: any): boolean { +export function isArray (value: any): value is any[] { // be compatible with IE 8 return toStr.call(value) === '[object Array]' } -export function isError (value: any): boolean { +export function isError (value: any): value is Error { const signature = toStr.call(value) // [object XXXError] return signature.substr(-6, 5) === 'Error' || @@ -102,7 +102,7 @@ export function last (arr: any[] | string): any | string { * @param {any} value The value to check. * @return {Boolean} Returns true if value is an object, else false. */ -export function isObject (value: any): boolean { +export function isObject (value: any): value is object { const type = typeof value return value !== null && (type === 'object' || type === 'function') } diff --git a/test/e2e/drop.ts b/test/e2e/drop.ts new file mode 100644 index 0000000000..fafec40d92 --- /dev/null +++ b/test/e2e/drop.ts @@ -0,0 +1,21 @@ +import Liquid from '../..' +import { expect, use } from 'chai' +import * as chaiAsPromised from 'chai-as-promised' + +use(chaiAsPromised) + +describe('drop', function () { + var engine: Liquid + beforeEach(function () { + engine = new Liquid() + }) + it('should test blank strings', async function () { + const src = ` + {% unless settings.fp_heading == blank %} +

{{ settings.fp_heading }}

+ {% endunless %}` + var ctx = { settings: { fp_heading: '' } } + const html = await engine.parseAndRender(src, ctx) + return expect(html).to.match(/^\s+$/) + }) +}) diff --git a/test/e2e/parse-and-render.ts b/test/e2e/parse-and-render.ts index a23e9d6ac3..b3216002b0 100644 --- a/test/e2e/parse-and-render.ts +++ b/test/e2e/parse-and-render.ts @@ -62,4 +62,9 @@ describe('.parseAndRender()', function () { const html = await engine.parseAndRender(src) return expect(html).to.equal('apples') }) + it('should support nil(null, undefined) literal', async function () { + const src = '{% if notexist == nil %}true{% endif %}' + const html = await engine.parseAndRender(src) + expect(html).to.equal('true') + }) }) diff --git a/test/unit/builtin/tags/case.ts b/test/unit/builtin/tags/case.ts index 483f5ec69c..8010335e4c 100644 --- a/test/unit/builtin/tags/case.ts +++ b/test/unit/builtin/tags/case.ts @@ -19,22 +19,20 @@ describe('tags/case', function () { const html = await liquid.parseAndRender(src) return expect(html).to.equal('foo') }) - it('should resolve empty string if not hit', async function () { - const src = '{% case empty %}' + - '{% when "foo" %}foo{% when ""%}bar' + - '{%endcase%}' - const ctx = { - empty: '' - } - const html = await liquid.parseAndRender(src, ctx) + it('should resolve blank as empty string', async function () { + const src = '{% case blank %}{% when ""%}bar{%endcase%}' + const html = await liquid.parseAndRender(src) + return expect(html).to.equal('bar') + }) + it('should resolve empty as empty string', async function () { + const src = '{% case empty %}{% when ""%}bar{%endcase%}' + const html = await liquid.parseAndRender(src) return expect(html).to.equal('bar') }) it('should accept empty string as branch name', async function () { - const src = '{% case false %}' + - '{% when "foo" %}foo{% when ""%}bar' + - '{%endcase%}' + const src = '{% case "" %}{% when ""%}bar{%endcase%}' const html = await liquid.parseAndRender(src) - return expect(html).to.equal('') + return expect(html).to.equal('bar') }) it('should support boolean case', async function () { const src = '{% case false %}' + diff --git a/test/unit/builtin/tags/for.ts b/test/unit/builtin/tags/for.ts index 7f19d6450d..cf6993fa2d 100644 --- a/test/unit/builtin/tags/for.ts +++ b/test/unit/builtin/tags/for.ts @@ -1,12 +1,12 @@ import Liquid from 'src/liquid' import { expect, use } from 'chai' import * as chaiAsPromised from 'chai-as-promised' -import IContext from 'src/scope/icontext' +import { Context } from 'src/scope/scope' use(chaiAsPromised) describe('tags/for', function () { - let liquid: Liquid, ctx: IContext + let liquid: Liquid, ctx: Context before(function () { liquid = new Liquid() liquid.registerTag('throwingTag', { diff --git a/test/unit/drop/blank-drop.ts b/test/unit/drop/blank-drop.ts new file mode 100644 index 0000000000..75e54ea3b5 --- /dev/null +++ b/test/unit/drop/blank-drop.ts @@ -0,0 +1,52 @@ +import { expect } from 'chai' +import Liquid from 'src/liquid' + +describe('drop/blank-drop', function () { + let liquid: Liquid + before(() => (liquid = new Liquid())) + + it('render blank drop as blank string', async function () { + const html = await liquid.parseAndRender('{{blank}}') + expect(html).to.equal('') + }) + it('blank equals nil', async function () { + const src = '{%if blank == nil %}blank == nil{%else%}blank != nil{% endif %}' + const html = await liquid.parseAndRender(src) + expect(html).to.equal('blank == nil') + }) + it('false is blank', async function () { + const src = '{%if false == blank %}false == blank{%else%}false != blank{% endif %}' + const html = await liquid.parseAndRender(src) + expect(html).to.equal('false == blank') + }) + it('"" is blank', async function () { + const src = '{%if "" == blank %}"" == blank{%else%}"" != blank{% endif %}' + const html = await liquid.parseAndRender(src) + expect(html).to.equal('"" == blank') + }) + it('" " is blank', async function () { + const src = '{%if " " == blank %}" " == blank{%else%}" " != blank{% endif %}' + const html = await liquid.parseAndRender(src) + expect(html).to.equal('" " == blank') + }) + it('{} is blank', async function () { + const src = '{%if obj == blank %}{} == blank{%else%}{} != blank{% endif %}' + const html = await liquid.parseAndRender(src, { obj: {} }) + expect(html).to.equal('{} == blank') + }) + it('{foo: 1} is not blank', async function () { + const src = '{%if obj == blank %}{foo: 1} == blank{%else%}{foo: 1} != blank{% endif %}' + const html = await liquid.parseAndRender(src, { obj: { foo: 1 } }) + expect(html).to.equal('{foo: 1} != blank') + }) + it('[] is blank', async function () { + const src = '{%if arr == blank %}[] == blank{%else%}[] != blank{% endif %}' + const html = await liquid.parseAndRender(src, { arr: [] }) + expect(html).to.equal('[] == blank') + }) + it('[1] is not blank', async function () { + const src = '{%if arr == blank %}[1] == blank{%else%}[1] != blank{% endif %}' + const html = await liquid.parseAndRender(src, { arr: [1] }) + expect(html).to.equal('[1] != blank') + }) +}) diff --git a/test/unit/drop/empty-drop.ts b/test/unit/drop/empty-drop.ts new file mode 100644 index 0000000000..15382cb8aa --- /dev/null +++ b/test/unit/drop/empty-drop.ts @@ -0,0 +1,52 @@ +import { expect } from 'chai' +import Liquid from 'src/liquid' + +describe('drop/empty-drop', function () { + let liquid: Liquid + before(() => (liquid = new Liquid())) + + it('render empty drop as empty string', async function () { + const html = await liquid.parseAndRender('{{empty}}') + expect(html).to.equal('') + }) + it('nil is not empty', async function () { + const src = '{%if nil == empty %}nil == empty{%else%}nil != empty{% endif %}' + const html = await liquid.parseAndRender(src) + expect(html).to.equal('nil != empty') + }) + it('false is not empty', async function () { + const src = '{%if false == empty %}false == empty{%else%}false != empty{% endif %}' + const html = await liquid.parseAndRender(src) + expect(html).to.equal('false != empty') + }) + it('"" is empty', async function () { + const src = '{%if "" == empty %}"" == empty{%else%}"" != empty{% endif %}' + const html = await liquid.parseAndRender(src) + expect(html).to.equal('"" == empty') + }) + it('" " is not empty', async function () { + const src = '{%if " " == empty %}" " == empty{%else%}" " != empty{% endif %}' + const html = await liquid.parseAndRender(src) + expect(html).to.equal('" " != empty') + }) + it('{} is empty', async function () { + const src = '{%if obj == empty %}{} == empty{%else%}{} != empty{% endif %}' + const html = await liquid.parseAndRender(src, { obj: {} }) + expect(html).to.equal('{} == empty') + }) + it('{foo: 1} is not empty', async function () { + const src = '{%if obj == empty %}{foo: 1} == empty{%else%}{foo: 1} != empty{% endif %}' + const html = await liquid.parseAndRender(src, { obj: { foo: 1 } }) + expect(html).to.equal('{foo: 1} != empty') + }) + it('[] is empty', async function () { + const src = '{%if arr == empty %}[] == empty{%else%}[] != empty{% endif %}' + const html = await liquid.parseAndRender(src, { arr: [] }) + expect(html).to.equal('[] == empty') + }) + it('[1] is not empty', async function () { + const src = '{%if arr == empty %}[1] == empty{%else%}[1] != empty{% endif %}' + const html = await liquid.parseAndRender(src, { arr: [1] }) + expect(html).to.equal('[1] != empty') + }) +}) diff --git a/test/unit/drop/null-drop.ts b/test/unit/drop/null-drop.ts new file mode 100644 index 0000000000..05be2f399a --- /dev/null +++ b/test/unit/drop/null-drop.ts @@ -0,0 +1,36 @@ +import { expect } from 'chai' +import Liquid from 'src/liquid' + +describe('drop/null-drop', function () { + let liquid: Liquid + before(() => (liquid = new Liquid())) + + it('render nil as empty string', async function () { + const html = await liquid.parseAndRender('{{nil}}') + expect(html).to.equal('') + }) + it('render null as empty string', async function () { + const html = await liquid.parseAndRender('{{null}}') + expect(html).to.equal('') + }) + it('undefined variable should equal to null', async function () { + const src = '{%if foo == nil %}foo == nil{%else%}foo != nil{% endif %}' + const html = await liquid.parseAndRender(src) + expect(html).to.equal('foo == nil') + }) + it('nil equals blank', async function () { + const src = '{%if nil == blank %}nil == blank{%else%}nil != blank{% endif %}' + const html = await liquid.parseAndRender(src) + expect(html).to.equal('nil == blank') + }) + it('0 should not equal to null', async function () { + const src = '{%if 0 == null %}0 == null{%else%}0 != null{% endif %}' + const html = await liquid.parseAndRender(src) + expect(html).to.equal('0 != null') + }) + it('nil should equal to null', async function () { + const src = '{%if nil == null %}nil == null{%else%}nil != null{% endif %}' + const html = await liquid.parseAndRender(src) + expect(html).to.equal('nil == null') + }) +}) diff --git a/test/unit/render/syntax.ts b/test/unit/render/syntax.ts index d707159e8b..562a897dbc 100644 --- a/test/unit/render/syntax.ts +++ b/test/unit/render/syntax.ts @@ -2,7 +2,7 @@ import Scope from 'src/scope/scope' import { expect } from 'chai' import { evalExp, evalValue, isTruthy } from 'src/render/syntax' -describe('expression', function () { +describe('render/syntax', function () { let scope: Scope beforeEach(function () { @@ -29,10 +29,16 @@ describe('expression', function () { expect(evalValue('-23.', scope)).to.equal(-23) expect(evalValue('23', scope)).to.equal(23) }) - it('should eval literal', function () { + it('should eval string literal', function () { expect(evalValue('"ab\'c"', scope)).to.equal("ab'c") expect(evalValue("'ab\"c'", scope)).to.equal('ab"c') }) + it('should eval nil literal', function () { + expect(evalValue('nil', scope)).to.be.null + }) + it('should eval null literal', function () { + expect(evalValue('null', scope)).to.be.null + }) it('should eval scope variables', function () { expect(evalValue('one', scope)).to.equal(1) expect(evalValue('has_value?', scope)).to.equal(true) diff --git a/test/unit/scope/scope.ts b/test/unit/scope/scope.ts index d851adebb5..3614ec0f08 100644 --- a/test/unit/scope/scope.ts +++ b/test/unit/scope/scope.ts @@ -1,11 +1,10 @@ import * as chai from 'chai' -import Scope from 'src/scope/scope' -import IContext from 'src/scope/icontext' +import Scope, { Context } from 'src/scope/scope' const expect = chai.expect describe('scope', function () { - let scope: Scope, ctx: IContext + let scope: Scope, ctx: Context beforeEach(function () { ctx = { foo: 'zoo', @@ -64,7 +63,7 @@ describe('scope', function () { expect(scope.get('foo')).equal('zoo') }) - it('should get undefined property', function () { + it('undefined property should yield undefined', function () { function fn () { scope.get('notdefined') }