test: 💍 add some tests for the cli

This commit is contained in:
Christian Kaisermann 2019-11-28 22:57:05 -03:00
parent 60ba73682f
commit 15ed03185a
5 changed files with 155 additions and 109 deletions

View File

@ -1,53 +1,35 @@
import { import {
Node, Node,
ObjectExpression, ObjectExpression,
Property,
ImportDeclaration, ImportDeclaration,
ImportSpecifier, ImportSpecifier,
CallExpression, CallExpression,
Identifier, Identifier,
Literal,
} from 'estree' } from 'estree'
import delve from 'dlv' import delve from 'dlv'
import { walk } from 'estree-walker' import { walk } from 'estree-walker'
import { Ast } from 'svelte/types/compiler/interfaces' import { Ast } from 'svelte/types/compiler/interfaces'
import { parse } from 'svelte/compiler' import { parse } from 'svelte/compiler'
import { deepSet } from './includes/deepSet'
import { getObjFromExpression } from './includes/getObjFromExpression'
import { Message } from './types'
const LIB_NAME = 'svelte-i18n' const LIB_NAME = 'svelte-i18n'
const DEFINE_MESSAGES_METHOD_NAME = 'defineMessages' const DEFINE_MESSAGES_METHOD_NAME = 'defineMessages'
const FORMAT_METHOD_NAMES = new Set(['format', '_']) const FORMAT_METHOD_NAMES = new Set(['format', '_', 't'])
const IGNORED_UTILITIES = new Set(['number', 'date', 'time'])
interface Message {
node: Node
meta: {
id?: string
default?: string
[key: string]: any
}
}
const isNumberString = (n: string) => !Number.isNaN(parseInt(n))
function deepSet(obj: any, path: string, value: any) {
const parts = path.replace(/\[(\w+)\]/gi, '.$1').split('.')
return parts.reduce((ref, part, i) => {
if (part in ref) return (ref = ref[part])
if (i < parts.length - 1) {
if (isNumberString(parts[i + 1])) {
return (ref = ref[part] = [])
}
return (ref = ref[part] = {})
}
return (ref[part] = value)
}, obj)
}
function isFormatCall(node: Node, imports: Set<string>) { function isFormatCall(node: Node, imports: Set<string>) {
if (node.type !== 'CallExpression') return false if (node.type !== 'CallExpression') return false
let identifier: Identifier let identifier: Identifier
if (node.callee.type === 'MemberExpression') { if (
node.callee.type === 'MemberExpression' &&
node.callee.property.type === 'Identifier' &&
!IGNORED_UTILITIES.has(node.callee.property.name)
) {
identifier = node.callee.object as Identifier identifier = node.callee.object as Identifier
} else if (node.callee.type === 'Identifier') { } else if (node.callee.type === 'Identifier') {
identifier = node.callee identifier = node.callee
@ -92,22 +74,6 @@ function getFormatSpecifiers(decl: ImportDeclaration) {
) as ImportSpecifier[] ) as ImportSpecifier[]
} }
function getObjFromExpression(exprNode: Node | ObjectExpression) {
if (exprNode.type !== 'ObjectExpression') return null
return exprNode.properties.reduce<Message>(
(acc, prop: Property) => {
// we only want primitives
if (prop.value.type !== 'Literal') return acc
if (prop.value.value !== Object(prop.value.value)) {
const key = (prop.key as Identifier).name as string
acc.meta[key] = prop.value.value
}
return acc
},
{ node: exprNode, meta: {} }
)
}
export function collectFormatCalls(ast: Ast) { export function collectFormatCalls(ast: Ast) {
const importDecls = getLibImportDeclarations(ast) const importDecls = getLibImportDeclarations(ast)
@ -122,14 +88,14 @@ export function collectFormatCalls(ast: Ast) {
if (imports.size === 0) return [] if (imports.size === 0) return []
const calls: CallExpression[] = [] const calls: CallExpression[] = []
function formatCallsWalker(node: Node) { function enter(node: Node) {
if (isFormatCall(node, imports)) { if (isFormatCall(node, imports)) {
calls.push(node as CallExpression) calls.push(node as CallExpression)
this.skip() this.skip()
} }
} }
walk(ast.instance as any, { enter: formatCallsWalker }) walk(ast.instance as any, { enter })
walk(ast.html as any, { enter: formatCallsWalker }) walk(ast.html as any, { enter })
return calls return calls
} }
@ -157,7 +123,9 @@ export function collectMessageDefinitions(ast: Ast) {
}) })
return definitions.flatMap(definitionDict => return definitions.flatMap(definitionDict =>
definitionDict.properties.map(propNode => propNode.value) definitionDict.properties.map(
propNode => propNode.value as ObjectExpression
)
) )
} }
@ -173,19 +141,18 @@ export function collectMessages(markup: string): Message[] {
return getObjFromExpression(pathNode) return getObjFromExpression(pathNode)
} }
if (pathNode.type !== 'Literal' || typeof pathNode.value !== 'string') { const node = pathNode as Literal
return null const id = node.value as string
}
if (options && options.type === 'ObjectExpression') { if (options && options.type === 'ObjectExpression') {
const messageObj = getObjFromExpression(options) const messageObj = getObjFromExpression(options)
messageObj.meta.id = pathNode.value messageObj.meta.id = id
return messageObj return messageObj
} }
return { return {
node: pathNode, node,
meta: { id: pathNode.value }, meta: { id },
} }
}), }),
].filter(Boolean) ].filter(Boolean)

View File

@ -0,0 +1,17 @@
const isNumberString = (n: string) => !Number.isNaN(parseInt(n))
export function deepSet(obj: any, path: string, value: any) {
const parts = path.replace(/\[(\w+)\]/gi, '.$1').split('.')
return parts.reduce((ref, part, i) => {
if (part in ref) return (ref = ref[part])
if (i < parts.length - 1) {
if (isNumberString(parts[i + 1])) {
return (ref = ref[part] = [])
}
return (ref = ref[part] = {})
}
return (ref[part] = value)
}, obj)
}

View File

@ -0,0 +1,18 @@
import { ObjectExpression, Property, Identifier } from 'estree'
import { Message } from '../types'
export function getObjFromExpression(exprNode: ObjectExpression) {
return exprNode.properties.reduce<Message>(
(acc, prop: Property) => {
if (prop.value.type !== 'Literal') return acc
// we only want primitives
if (prop.value.value !== Object(prop.value.value)) {
const key = (prop.key as Identifier).name as string
acc.meta[key] = prop.value.value
}
return acc
},
{ node: exprNode, meta: {} }
)
}

10
src/cli/types/index.ts Normal file
View File

@ -0,0 +1,10 @@
import { Node } from 'estree'
export interface Message {
node: Node
meta: {
id?: string
default?: string
[key: string]: any
}
}

View File

@ -10,19 +10,33 @@ import {
} from '../../src/cli/extract' } from '../../src/cli/extract'
describe('collecting format calls', () => { describe('collecting format calls', () => {
it('should return nothing if there are no imports from the library', () => { test('returns nothing if there are no imports', () => {
const ast = parse(`<script>const $_ = () => 0; $_();</script>`) const ast = parse(`<script>
import Foo from 'foo';
const $_ = () => 0; $_();
</script>`)
const calls = collectFormatCalls(ast) const calls = collectFormatCalls(ast)
expect(calls).toHaveLength(0) expect(calls).toHaveLength(0)
}) })
it('should collect all format calls in the instance script', () => { test('returns nothing if there are no format imports', () => {
const ast = parse(
`<script>
import { init } from 'svelte-i18n';
init({})
</script>`
)
const calls = collectFormatCalls(ast)
expect(calls).toHaveLength(0)
})
test('collects all format calls in the instance script', () => {
const ast = parse(`<script> const ast = parse(`<script>
import { format, _ } from 'svelte-i18n' import { format, _ } from 'svelte-i18n'
$format('foo') $format('foo')
format('bar') format('bar')
let label = $_({id:'bar'}) let label = $_({id:'bar'})
const a = { b: () => 0} const a = { b: () => 0}
</script>`) </script>`)
const calls = collectFormatCalls(ast) const calls = collectFormatCalls(ast)
expect(calls).toHaveLength(2) expect(calls).toHaveLength(2)
@ -30,24 +44,12 @@ describe('collecting format calls', () => {
expect(calls[1]).toMatchObject({ type: 'CallExpression' }) expect(calls[1]).toMatchObject({ type: 'CallExpression' })
}) })
it('should collect all format calls with renamed imports', () => { test('collects all format calls with renamed imports', () => {
const ast = parse(`<script> const ast = parse(`<script>
import { format as _x, _ as intl } from 'svelte-i18n' import { format as _x, _ as intl, t as f } from 'svelte-i18n'
$_x('foo') $_x('foo')
$intl({ id: 'bar' }) $intl({ id: 'bar' })
</script>`) $f({ id: 'bar' })
const calls = collectFormatCalls(ast)
expect(calls).toHaveLength(2)
expect(calls[0]).toMatchObject({ type: 'CallExpression' })
expect(calls[1]).toMatchObject({ type: 'CallExpression' })
})
it('should collect all format utility calls', () => {
const ast = parse(`<script>
import { _ } from 'svelte-i18n'
$_.title('foo')
$_.capitalize({ id: 'bar' })
$_.number(10000)
</script>`) </script>`)
const calls = collectFormatCalls(ast) const calls = collectFormatCalls(ast)
expect(calls).toHaveLength(3) expect(calls).toHaveLength(3)
@ -55,24 +57,51 @@ describe('collecting format calls', () => {
expect(calls[1]).toMatchObject({ type: 'CallExpression' }) expect(calls[1]).toMatchObject({ type: 'CallExpression' })
expect(calls[2]).toMatchObject({ type: 'CallExpression' }) expect(calls[2]).toMatchObject({ type: 'CallExpression' })
}) })
test('collects all string format utility calls', () => {
const ast = parse(`<script>
import { _ } from 'svelte-i18n'
$_.title('foo')
$_.capitalize({ id: 'bar' })
$_.lower({ id: 'bar' })
$_.upper({ id: 'bar' })
</script>`)
const calls = collectFormatCalls(ast)
expect(calls).toHaveLength(4)
expect(calls[0]).toMatchObject({ type: 'CallExpression' })
expect(calls[1]).toMatchObject({ type: 'CallExpression' })
expect(calls[2]).toMatchObject({ type: 'CallExpression' })
expect(calls[3]).toMatchObject({ type: 'CallExpression' })
})
test('ignores date, time and number calls', () => {
const ast = parse(`<script>
import { _ } from 'svelte-i18n'
$_.number(1000)
$_.date(new Date())
$_.time(new Date())
</script>`)
const calls = collectFormatCalls(ast)
expect(calls).toHaveLength(0)
})
}) })
describe('collecting message definitions', () => { describe('collecting message definitions', () => {
it('should return nothing if there are no imports from the library', () => { test('returns nothing if there are no imports from the library', () => {
const ast = parse( const ast = parse(
`<script> `<script>
import foo from 'foo'; import foo from 'foo';
import { dictionary } from 'svelte-i18n'; import { dictionary } from 'svelte-i18n';
</script>`, </script>`
) )
expect(collectMessageDefinitions(ast)).toHaveLength(0) expect(collectMessageDefinitions(ast)).toHaveLength(0)
}) })
it('should get all message definition objects', () => { test('gets all message definition objects', () => {
const ast = parse(`<script> const ast = parse(`<script>
import { defineMessages } from 'svelte-i18n'; import { defineMessages } from 'svelte-i18n';
defineMessages({ foo: { id: 'foo' }, bar: { id: 'bar' } }) defineMessages({ foo: { id: 'foo' }, bar: { id: 'bar' } })
defineMessages({ baz: { id: 'baz' }, quix: { id: 'qux' } }) defineMessages({ baz: { id: 'baz' }, quix: { id: 'qux' } })
</script>`) </script>`)
const definitions = collectMessageDefinitions(ast) const definitions = collectMessageDefinitions(ast)
expect(definitions).toHaveLength(4) expect(definitions).toHaveLength(4)
@ -84,10 +113,11 @@ describe('collecting message definitions', () => {
}) })
describe('collecting messages', () => { describe('collecting messages', () => {
it('should collect all messages in both instance and html ASTs', () => { test('collects all messages in both instance and html ASTs', () => {
const markup = ` const markup = `
<script> <script>
import { _, defineMessages } from 'svelte-i18n'; import { _, defineMessages } from 'svelte-i18n';
console.log($_({ id: 'foo' })) console.log($_({ id: 'foo' }))
console.log($_.title({ id: 'page.title' })) console.log($_.title({ id: 'page.title' }))
@ -96,10 +126,13 @@ describe('collecting messages', () => {
disabled: { id: 'disabled', default: 'Disabled' } disabled: { id: 'disabled', default: 'Disabled' }
}) })
</script> </script>
<div>{$_('msg_1')}</div> <div>{$_('msg_1')}</div>
<div>{$_({id: 'msg_2'})}</div> <div>{$_({id: 'msg_2'})}</div>
<div>{$_('msg_3', { default: 'Message'})}</div>` <div>{$_('msg_3', { default: 'Message'})}</div>`
const messages = collectMessages(markup) const messages = collectMessages(markup)
expect(messages).toHaveLength(7) expect(messages).toHaveLength(7)
expect(messages).toEqual( expect(messages).toEqual(
expect.arrayContaining([ expect.arrayContaining([
@ -114,15 +147,16 @@ describe('collecting messages', () => {
expect.objectContaining({ expect.objectContaining({
meta: { id: 'enabled', default: 'Enabled' }, meta: { id: 'enabled', default: 'Enabled' },
}), }),
]), ])
) )
}) })
}) })
describe('messages extraction', () => { describe('messages extraction', () => {
it('should return a object built based on all found message paths', () => { test('returns a object built based on all found message paths', () => {
const markup = ` const markup = `<script>
<script>import { _ } from 'svelte-i18n';</script> import { _ } from 'svelte-i18n';
</script>
<h1>{$_.title('title')}</h1> <h1>{$_.title('title')}</h1>
<h2>{$_({ id: 'subtitle'})}</h2> <h2>{$_({ id: 'subtitle'})}</h2>
@ -131,16 +165,16 @@ describe('messages extraction', () => {
expect(dict).toMatchObject({ title: '', subtitle: '' }) expect(dict).toMatchObject({ title: '', subtitle: '' })
}) })
it('creates deep nested properties', () => { test('creates deep nested properties', () => {
const markup = ` const markup = `
<script>import { _ } from 'svelte-i18n';</script> <script>import { _ } from 'svelte-i18n';</script>
<h1>{$_.title('home.page.title')}</h1> <h1>{$_.title('home.page.title')}</h1>
<h2>{$_({ id: 'home.page.subtitle'})}</h2> <h2>{$_({ id: 'home.page.subtitle'})}</h2>
<ul> <ul>
<li>{$_('list[0]')}</li> <li>{$_('list.0')}</li>
<li>{$_('list[1]')}</li> <li>{$_('list.1')}</li>
<li>{$_('list[2]')}</li> <li>{$_('list.2')}</li>
</ul> </ul>
` `
const dict = extractMessages(markup) const dict = extractMessages(markup)
@ -150,38 +184,38 @@ describe('messages extraction', () => {
}) })
}) })
it('creates a shallow dictionary', () => { test('creates a shallow dictionary', () => {
const markup = ` const markup = `
<script>import { _ } from 'svelte-i18n';</script> <script>import { _ } from 'svelte-i18n';</script>
<h1>{$_.title('home.page.title')}</h1> <h1>{$_.title('home.page.title')}</h1>
<h2>{$_({ id: 'home.page.subtitle'})}</h2> <h2>{$_({ id: 'home.page.subtitle'})}</h2>
<ul> <ul>
<li>{$_('list[0]')}</li> <li>{$_('list.0')}</li>
<li>{$_('list[1]')}</li> <li>{$_('list.1')}</li>
<li>{$_('list[2]')}</li> <li>{$_('list.2')}</li>
</ul> </ul>
` `
const dict = extractMessages(markup, { shallow: true }) const dict = extractMessages(markup, { shallow: true })
expect(dict).toMatchObject({ expect(dict).toMatchObject({
'home.page.title': '', 'home.page.title': '',
'home.page.subtitle': '', 'home.page.subtitle': '',
'list[0]': '', 'list.0': '',
'list[1]': '', 'list.1': '',
'list[2]': '', 'list.2': '',
}) })
}) })
it('allow to pass a initial dictionary and only append non-existing props', () => { test('allow to pass a initial dictionary and only append non-existing props', () => {
const markup = ` const markup = `
<script>import { _ } from 'svelte-i18n';</script> <script>import { _ } from 'svelte-i18n';</script>
<h1>{$_.title('home.page.title')}</h1> <h1>{$_.title('home.page.title')}</h1>
<h2>{$_({ id: 'home.page.subtitle'})}</h2> <h2>{$_({ id: 'home.page.subtitle'})}</h2>
<ul> <ul>
<li>{$_('list[0]')}</li> <li>{$_('list.0')}</li>
<li>{$_('list[1]')}</li> <li>{$_('list.1')}</li>
<li>{$_('list[2]')}</li> <li>{$_('list.2')}</li>
</ul> </ul>
` `
const dict = extractMessages(markup, { const dict = extractMessages(markup, {
@ -205,7 +239,7 @@ describe('messages extraction', () => {
}) })
}) })
it('allow to pass a initial dictionary and only append shallow non-existing props', () => { test('allow to pass a initial dictionary and only append shallow non-existing props', () => {
const markup = ` const markup = `
<script>import { _ } from 'svelte-i18n';</script> <script>import { _ } from 'svelte-i18n';</script>