├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── rockspecs ├── reactor-scm-1.rockspec └── reactor-scm-2.rockspec ├── src └── reactor │ ├── component.lua │ ├── components │ ├── mesh.lua │ ├── shader.lua │ └── text.lua │ ├── draw.lua │ ├── helpers │ ├── createLeafNode.lua │ └── getPrintableValue.lua │ ├── init.lua │ ├── invoke.lua │ ├── lifecycle.lua │ ├── propTypes │ ├── boolean.lua │ ├── callable.lua │ ├── function.lua │ ├── number.lua │ ├── oneOf.lua │ ├── optional.lua │ ├── string.lua │ ├── table.lua │ ├── tableShape.lua │ └── value.lua │ ├── reconcile.lua │ ├── registry.lua │ └── utils │ ├── funcUtils.lua │ ├── logUtils.lua │ └── tableUtils.lua ├── test.sh ├── tests ├── .luacheckrc ├── libs │ └── lust.lua ├── runner.lua ├── specs │ └── reactor │ │ ├── component.lua │ │ ├── invoke.lua │ │ ├── lifecycle.lua │ │ ├── propTypes │ │ ├── boolean.lua │ │ ├── callable.lua │ │ ├── function.lua │ │ ├── number.lua │ │ ├── oneOf.lua │ │ ├── optional.lua │ │ ├── string.lua │ │ ├── table.lua │ │ ├── tableShape.lua │ │ └── value.lua │ │ └── reconcile.lua └── suite.lua └── watch.sh /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Lua sources 2 | luac.out 3 | 4 | # luarocks build files 5 | *.src.rock 6 | *.zip 7 | *.tar.gz 8 | 9 | # Object files 10 | *.o 11 | *.os 12 | *.ko 13 | *.obj 14 | *.elf 15 | 16 | # Precompiled Headers 17 | *.gch 18 | *.pch 19 | 20 | # Libraries 21 | *.lib 22 | *.a 23 | *.la 24 | *.lo 25 | *.def 26 | *.exp 27 | 28 | # Shared objects (inc. Windows DLLs) 29 | *.dll 30 | *.so 31 | *.so.* 32 | *.dylib 33 | 34 | # Executables 35 | *.exe 36 | *.out 37 | *.app 38 | *.i*86 39 | *.x86_64 40 | *.hex 41 | 42 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: c 2 | sudo: false 3 | 4 | addons: 5 | apt: 6 | packages: 7 | - lua5.1 8 | - lua5.2 9 | - luajit 10 | 11 | branches: 12 | only: 13 | - master 14 | 15 | script: 16 | - lua5.1 tests/runner.lua 17 | - lua5.2 tests/runner.lua 18 | - luajit-2.0.0-beta9 tests/runner.lua -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Daniel Richards 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lua-reactor 2 | React-style ui component system for Lua 3 | 4 | Currently can render a few primitives (text, meshes, and shaders) using love2d 5 | 6 | More components coming soon ... 7 | 8 | ### TODO 9 | - Continous integration 10 | - Component unmounting 11 | - PropType validation 12 | - Context 13 | - More component types 14 | - More tests 15 | - Extract things like PropTypes and components into separate modules 16 | 17 | ### Declare a component 18 | ``` 19 | local component = require('reactor').component 20 | local text = require('reactor.components.text') 21 | 22 | return component{ 23 | name = 'helloText', 24 | render = function(props) 25 | return text{ 26 | value = 'Hello, ' .. props.name, 27 | x = 0, 28 | y = 0, 29 | width = 100 30 | } 31 | end 32 | } 33 | ``` 34 | 35 | ### Draw components 36 | ``` 37 | local helloText = require('helloText') 38 | local draw = require('reactor').draw 39 | 40 | draw(helloText{ 41 | name = 'World!' 42 | }) 43 | ``` 44 | 45 | ### The render function can return arrays of components 46 | ``` 47 | local helloText = require('helloText') 48 | local component = require('reactor').component 49 | local map = require('map') 50 | 51 | return component{ 52 | name = 'helloMessages', 53 | render = function(props) 54 | local helloMessages = map(props.names, function(name) 55 | return helloText({ 56 | name = name 57 | }) 58 | end) 59 | return helloMessages 60 | end 61 | } 62 | ``` -------------------------------------------------------------------------------- /rockspecs/reactor-scm-1.rockspec: -------------------------------------------------------------------------------- 1 | package = "reactor" 2 | version = "scm-1" 3 | source = { 4 | url = "git://github.com/talldan/lua-reactor.git" 5 | } 6 | description = { 7 | summary = "React-style ui component system for Lua", 8 | detailed = [[ 9 | React-style ui component system for Lua. 10 | ]], 11 | homepage = "https://github.com/talldan/lua-reactor", 12 | license = "MIT" 13 | } 14 | dependencies = { 15 | "lua >= 5.1" 16 | } 17 | build = { 18 | type = "builtin", 19 | modules = { 20 | ["reactor"] = "src/reactor.lua", 21 | ["component"] = "src/component.lua", 22 | ["draw"] = "src/draw.lua", 23 | ["invoke"] = "src/invoke.lua", 24 | ["lifecycle"] = "src/lifecycle.lua", 25 | ["reconcile"] = "src/reconcile.lua", 26 | ["registry"] = "src/registry.lua", 27 | ["helpers.createLeafNode"] = "src/helpers/createLeafNode.lua", 28 | ["helpers.getPrintableValue"] = "src/helpers/getPrintableValue.lua", 29 | ["utils.funcUtils"] = "src/utils/funcUtils.lua", 30 | ["utils.tableUtils"] = "src/utils/tableUtils.lua", 31 | ["components.mesh"] = "src/components/mesh.lua", 32 | ["components.text"] = "src/components/text.lua", 33 | ["components.shader"] = "src/components/shader.lua", 34 | ["propTypes.boolean"] = "src/propTypes/boolean.lua", 35 | ["propTypes.callable"] = "src/propTypes/callable.lua", 36 | ["propTypes.function"] = "src/propTypes/function.lua", 37 | ["propTypes.number"] = "src/propTypes/number.lua", 38 | ["propTypes.oneOf"] = "src/propTypes/oneOf.lua", 39 | ["propTypes.optional"] = "src/propTypes/optional.lua", 40 | ["propTypes.string"] = "src/propTypes/string.lua", 41 | ["propTypes.table"] = "src/propTypes/table.lua", 42 | ["propTypes.tableShape"] = "src/propTypes/tableShape.lua", 43 | ["propTypes.value"] = "src/propTypes/value.lua" 44 | } 45 | } -------------------------------------------------------------------------------- /rockspecs/reactor-scm-2.rockspec: -------------------------------------------------------------------------------- 1 | package = "reactor" 2 | version = "scm-2" 3 | source = { 4 | url = "git://github.com/talldan/lua-reactor.git" 5 | } 6 | description = { 7 | summary = "React-style ui component system for Lua", 8 | detailed = [[ 9 | React-style ui component system for Lua. 10 | ]], 11 | homepage = "https://github.com/talldan/lua-reactor", 12 | license = "MIT" 13 | } 14 | dependencies = { 15 | "lua >= 5.1" 16 | } 17 | build = { 18 | type = "builtin", 19 | modules = { 20 | ["reactor.init"] = "src/reactor/init.lua", 21 | ["reactor.component"] = "src/reactor/component.lua", 22 | ["reactor.draw"] = "src/reactor/draw.lua", 23 | ["reactor.invoke"] = "src/reactor/invoke.lua", 24 | ["reactor.lifecycle"] = "src/reactor/lifecycle.lua", 25 | ["reactor.reconcile"] = "src/reactor/reconcile.lua", 26 | ["reactor.registry"] = "src/reactor/registry.lua", 27 | ["reactor.helpers.createLeafNode"] = "src/reactor/helpers/createLeafNode.lua", 28 | ["reactor.helpers.getPrintableValue"] = "src/reactor/helpers/getPrintableValue.lua", 29 | ["reactor.utils.funcUtils"] = "src/reactor/utils/funcUtils.lua", 30 | ["reactor.utils.tableUtils"] = "src/reactor/utils/tableUtils.lua", 31 | ["reactor.components.mesh"] = "src/reactor/components/mesh.lua", 32 | ["reactor.components.text"] = "src/reactor/components/text.lua", 33 | ["reactor.components.shader"] = "src/reactor/components/shader.lua", 34 | ["reactor.propTypes.boolean"] = "src/reactor/propTypes/boolean.lua", 35 | ["reactor.propTypes.callable"] = "src/reactor/propTypes/callable.lua", 36 | ["reactor.propTypes.function"] = "src/reactor/propTypes/function.lua", 37 | ["reactor.propTypes.number"] = "src/reactor/propTypes/number.lua", 38 | ["reactor.propTypes.oneOf"] = "src/reactor/propTypes/oneOf.lua", 39 | ["reactor.propTypes.optional"] = "src/reactor/propTypes/optional.lua", 40 | ["reactor.propTypes.string"] = "src/reactor/propTypes/string.lua", 41 | ["reactor.propTypes.table"] = "src/reactor/propTypes/table.lua", 42 | ["reactor.propTypes.tableShape"] = "src/reactor/propTypes/tableShape.lua", 43 | ["reactor.propTypes.value"] = "src/reactor/propTypes/value.lua" 44 | } 45 | } -------------------------------------------------------------------------------- /src/reactor/component.lua: -------------------------------------------------------------------------------- 1 | local function componentModule(reactor) 2 | local function hasName(component) 3 | return type(component.name) == 'string' 4 | end 5 | 6 | local function hasRenderFunction(component) 7 | return type(component.render) == 'function' 8 | end 9 | 10 | local function validateComponent(component) 11 | assert(hasName(component), 12 | 'component declared without a name property') 13 | assert(hasRenderFunction(component), 14 | 'component declared without a render function') 15 | end 16 | 17 | return function(component) 18 | validateComponent(component) 19 | return reactor.lifecycle(component) 20 | end 21 | end 22 | 23 | return componentModule 24 | -------------------------------------------------------------------------------- /src/reactor/components/mesh.lua: -------------------------------------------------------------------------------- 1 | -- luacheck: globals love 2 | 3 | local createLeafNode = require('reactor.helpers.createLeafNode') 4 | 5 | local function create(props) 6 | local mode = props.mode or "fan" 7 | local usage = props.usage or "dynamic" 8 | local vertices = props.vertices 9 | local vertexFormat = props.vertexFormat 10 | 11 | if vertexFormat then 12 | return love.graphics.newMesh(vertexFormat, vertices, mode, usage) 13 | else 14 | return love.graphics.newMesh(vertices, mode, usage) 15 | end 16 | end 17 | 18 | local function update(previousProps, nextProps, drawable) 19 | -- todo - its hard to compare a list of vertices 20 | -- due to object equality issues, find a way to fix this 21 | -- if previous.vertices ~= next.vertices then 22 | -- drawable.setVertices(drawable, next.vertices) 23 | -- print('redraw') 24 | -- end 25 | 26 | if previousProps.mode ~= nextProps.mode then 27 | drawable.setDrawMode(nextProps.mode) 28 | end 29 | 30 | return drawable 31 | end 32 | 33 | local function draw(props, drawable) 34 | local x = props.x or 0 35 | local y = props.y or 0 36 | local rotation = props.rotation or 0 37 | local scaleX = props.scaleX or 1 38 | local scaleY = props.scaleY or 1 39 | 40 | love.graphics.draw(drawable, x, y, rotation, scaleX, scaleY) 41 | 42 | return drawable 43 | end 44 | 45 | local meshOperations = { 46 | create = create, 47 | update = update, 48 | draw = draw 49 | } 50 | 51 | return createLeafNode({ 52 | name = 'drawable', 53 | operations = meshOperations, 54 | canHaveChildren = false 55 | }); -------------------------------------------------------------------------------- /src/reactor/components/shader.lua: -------------------------------------------------------------------------------- 1 | -- luacheck: globals love 2 | 3 | local createLeafNode = require('reactor.helpers.createLeafNode') 4 | 5 | local function create(props) 6 | local vertexShader = props.vertexShader 7 | local pixelShader = props.pixelShader 8 | 9 | if vertexShader and pixelShader then 10 | return love.graphics.newShader(vertexShader, pixelShader) 11 | elseif vertexShader then 12 | return love.graphics.newShader(vertexShader) 13 | elseif pixelShader then 14 | return love.graphics.newShader(pixelShader) 15 | end 16 | end 17 | 18 | local function update(_, nextProps, drawable) 19 | if type(nextProps.externs) == 'table' then 20 | for key, value in pairs(nextProps.externs) do 21 | drawable:send(key, value) 22 | end 23 | end 24 | 25 | return drawable 26 | end 27 | 28 | local function draw(_, drawable) 29 | love.graphics.setShader(drawable) 30 | return drawable 31 | end 32 | 33 | local function postChildUpdate() 34 | love.graphics.setShader() 35 | end 36 | 37 | 38 | local shaderOperations = { 39 | create = create, 40 | update = update, 41 | draw = draw, 42 | postChildUpdate = postChildUpdate 43 | } 44 | 45 | return createLeafNode({ 46 | name = 'drawable', 47 | operations = shaderOperations, 48 | canHaveChildren = true 49 | }); 50 | -------------------------------------------------------------------------------- /src/reactor/components/text.lua: -------------------------------------------------------------------------------- 1 | -- luacheck: globals love 2 | 3 | local createLeafNode = require('reactor.helpers.createLeafNode') 4 | 5 | local function getFormattedText(props) 6 | local value = props.value 7 | local wraplimit = nil 8 | local alignment = 'left' 9 | 10 | if props.width ~= nil then 11 | wraplimit = props.width 12 | end 13 | 14 | if props.alignment ~= nil then 15 | alignment = props.alignment 16 | end 17 | 18 | return value, wraplimit, alignment 19 | end 20 | 21 | local function create(props) 22 | local fontSize = props.fontSize or 12 23 | local font = love.graphics.newFont(fontSize) 24 | local drawable = love.graphics.newText(font) 25 | drawable:setf(getFormattedText(props)) 26 | return drawable 27 | end 28 | 29 | local function update(previousProps, nextProps, drawable) 30 | if previousProps.value ~= nextProps.value then 31 | drawable:setf(getFormattedText(nextProps)) 32 | end 33 | 34 | return drawable 35 | end 36 | 37 | local function draw(props, drawable) 38 | local r, g, b, a = love.graphics.getColor() 39 | 40 | love.graphics.setColor( 41 | props.r or r, 42 | props.g or g, 43 | props.b or b, 44 | props.a or a 45 | ) 46 | 47 | love.graphics.draw(drawable, props.x, props.y) 48 | 49 | love.graphics.setColor(r, g, b, a) 50 | 51 | return drawable 52 | end 53 | 54 | local textOperations = { 55 | create = create, 56 | update = update, 57 | draw = draw 58 | } 59 | 60 | return createLeafNode({ 61 | name = 'text', 62 | operations = textOperations, 63 | canHaveChildren = false 64 | }); 65 | -------------------------------------------------------------------------------- /src/reactor/draw.lua: -------------------------------------------------------------------------------- 1 | local function drawModule(reactor) 2 | local previousDrawDescription 3 | 4 | local function draw(drawComponents) 5 | local drawDescription = drawComponents() 6 | local drawOperations = reactor.reconcile( 7 | drawDescription, 8 | previousDrawDescription 9 | ) 10 | reactor.invoke(drawOperations) 11 | previousDrawDescription = drawDescription 12 | end 13 | 14 | return draw 15 | end 16 | 17 | return drawModule -------------------------------------------------------------------------------- /src/reactor/helpers/createLeafNode.lua: -------------------------------------------------------------------------------- 1 | local curry = require('reactor.utils.funcUtils').curry 2 | local assign = require('reactor.utils.tableUtils').assign 3 | local component = require('reactor').component 4 | 5 | local function createLeafNode(componentConfig) 6 | local renderFunc = curry(function(props, path) 7 | return { 8 | name = componentConfig.name, 9 | operations = componentConfig.operations, 10 | canHaveChildren = componentConfig.canHaveChildren, 11 | props = props, 12 | path = path 13 | } 14 | end, 2) 15 | 16 | local configWithRenderFunc = assign({ 17 | render = renderFunc 18 | }, componentConfig) 19 | 20 | return component(configWithRenderFunc) 21 | end 22 | 23 | return createLeafNode -------------------------------------------------------------------------------- /src/reactor/helpers/getPrintableValue.lua: -------------------------------------------------------------------------------- 1 | local function getPrintableValue(value) 2 | if value == nil then 3 | return 'nil' 4 | elseif type(value) == 'function' then 5 | return 'a function' 6 | elseif value == true then 7 | return 'true' 8 | elseif value == false then 9 | return 'false' 10 | elseif type(value) == 'function' then 11 | return 'a function' 12 | elseif type(value) == 'table' then 13 | return 'a table' 14 | else 15 | return value 16 | end 17 | end 18 | 19 | return getPrintableValue -------------------------------------------------------------------------------- /src/reactor/init.lua: -------------------------------------------------------------------------------- 1 | local componentModule = require('reactor.component') 2 | local lifecycleModule = require('reactor.lifecycle') 3 | local registryModule = require('reactor.registry') 4 | local reconcileModule = require('reactor.reconcile') 5 | local invokeModule = require('reactor.invoke') 6 | local drawModule = require('reactor.draw') 7 | 8 | local reactor = {} 9 | 10 | reactor.registry = registryModule(reactor) 11 | reactor.component = componentModule(reactor) 12 | reactor.lifecycle = lifecycleModule(reactor) 13 | reactor.reconcile = reconcileModule(reactor) 14 | reactor.invoke = invokeModule(reactor) 15 | reactor.draw = drawModule(reactor) 16 | 17 | return reactor 18 | -------------------------------------------------------------------------------- /src/reactor/invoke.lua: -------------------------------------------------------------------------------- 1 | local optionalMap = require('reactor.utils.tableUtils').optionalMap 2 | 3 | local function invokeModule() 4 | local backingInstances = {} 5 | 6 | local function getCreationState(operation) 7 | if operation.create then 8 | return operation.create(operation.props) 9 | end 10 | end 11 | 12 | local function getUpdateState(operation, instance) 13 | if operation.update then 14 | local previousProps = nil 15 | local state = nil 16 | 17 | if type(instance) == 'table' then 18 | previousProps = instance.props 19 | state = instance.state 20 | end 21 | 22 | return operation.update(previousProps, operation.props, state) 23 | end 24 | end 25 | 26 | local function getDrawState(operation, instance) 27 | local state = nil 28 | 29 | if operation.draw then 30 | if type(instance) == 'table' then 31 | state = instance.state 32 | end 33 | return operation.draw(operation.props, state) 34 | end 35 | end 36 | 37 | local function createInstance(operation) 38 | local createdInstance = { 39 | state = getCreationState(operation), 40 | props = operation.props 41 | } 42 | 43 | return createdInstance, operation.path 44 | end 45 | 46 | local function updateInstance(operation, instance) 47 | local updatedInstance = { 48 | state = getUpdateState(operation, instance), 49 | props = operation.props 50 | } 51 | return updatedInstance, operation.path 52 | end 53 | 54 | local function drawInstance(operation, instance) 55 | local drawnInstance = { 56 | state = getDrawState(operation, instance), 57 | props = operation.props 58 | } 59 | return drawnInstance, operation.path 60 | end 61 | 62 | local function deleteInstance(operation) 63 | if operation.delete then 64 | operation.delete(operation.props) 65 | end 66 | end 67 | 68 | local function postChildUpdate(operation) 69 | if operation.postChildUpdate then 70 | operation.postChildUpdate(operation.props) 71 | end 72 | end 73 | 74 | local function getInstance(operation) 75 | return backingInstances[operation.path] 76 | end 77 | 78 | local function invoke(operations) 79 | backingInstances = optionalMap(function(operation) 80 | if operation['operationType'] == 'create' then 81 | local instance = createInstance(operation) 82 | return drawInstance(operation, updateInstance(operation, instance)) 83 | elseif operation['operationType'] == 'update' then 84 | local instance = getInstance(operation) 85 | return drawInstance(operation, updateInstance(operation, instance)) 86 | elseif operation['operationType'] == 'delete' then 87 | local instance = getInstance(operation) 88 | return deleteInstance(operation, instance) 89 | elseif operation['operationType'] == 'postChildUpdate' then 90 | local instance = getInstance(operation) 91 | return postChildUpdate(operation, instance) 92 | end 93 | end, operations) 94 | 95 | return backingInstances 96 | end 97 | 98 | return invoke 99 | end 100 | 101 | return invokeModule -------------------------------------------------------------------------------- /src/reactor/lifecycle.lua: -------------------------------------------------------------------------------- 1 | local curry = require('reactor.utils.funcUtils').curry 2 | local map = require('reactor.utils.tableUtils').map 3 | 4 | local function lifecycleModule(reactor) 5 | local function updatePath(component, props, path) 6 | local updatedPath 7 | local key = props and props.key 8 | local name = component and component.name 9 | local pathPart = key or name 10 | 11 | if not path then 12 | path = '' 13 | end 14 | 15 | if path ~= '' then 16 | updatedPath = path .. '.' .. pathPart 17 | else 18 | updatedPath = pathPart 19 | end 20 | 21 | return updatedPath 22 | end 23 | 24 | local function preRender(component, props, lastProps) 25 | if lastProps then 26 | if component.willUpdate then 27 | component.willUpdate(props, lastProps) 28 | end 29 | else 30 | if component.willMount then 31 | component.willMount(props) 32 | end 33 | end 34 | end 35 | 36 | local function renderTable(componentRenderers, path) 37 | return map(function(render) 38 | return render(path) 39 | end, componentRenderers) 40 | end 41 | 42 | local function render(renderer, path) 43 | if type(renderer) == 'table' then 44 | return renderTable(renderer, path) 45 | elseif type(renderer) == 'function' then 46 | return renderer(path) 47 | end 48 | end 49 | 50 | local function doRender(component, props, path) 51 | local renderer = component.render(props) 52 | local renderOutput 53 | 54 | if props.children then 55 | renderOutput = { 56 | parent = render(renderer, path), 57 | children = render(props.children, path .. '.children') 58 | } 59 | else 60 | renderOutput = render(renderer, path) 61 | end 62 | 63 | reactor.registry.mountComponentIfRequired(component, props, path) 64 | 65 | return renderOutput 66 | end 67 | 68 | local function postRender(component, props, lastProps) 69 | if lastProps then 70 | if component.didUpdate then 71 | component.didUpdate(props, lastProps) 72 | end 73 | else 74 | if component.didMount then 75 | component.didMount(props) 76 | end 77 | end 78 | end 79 | 80 | local function lifecycle(component, props, path) 81 | local renderPath = updatePath(component, props, path) 82 | local lastProps = reactor.registry.getLastProps(renderPath) 83 | 84 | preRender(component, props, lastProps) 85 | local renderOutput = doRender(component, props, renderPath) 86 | postRender(component, props, lastProps) 87 | 88 | return renderOutput 89 | end 90 | 91 | return curry(lifecycle, 3) 92 | end 93 | 94 | return lifecycleModule -------------------------------------------------------------------------------- /src/reactor/propTypes/boolean.lua: -------------------------------------------------------------------------------- 1 | local function getFailureReason(actualValue) 2 | return 'Failed to validate prop as boolean, instead saw ' .. type(actualValue) 3 | end 4 | 5 | local function validateBoolean() 6 | return function(toValidate) 7 | local isValid = type(toValidate) == 'boolean' 8 | local reason = nil 9 | 10 | if not isValid then 11 | reason = getFailureReason(toValidate) 12 | end 13 | 14 | return isValid, reason 15 | end 16 | end 17 | 18 | return validateBoolean -------------------------------------------------------------------------------- /src/reactor/propTypes/callable.lua: -------------------------------------------------------------------------------- 1 | local function getFailureReason(actualValue) 2 | local valueType = type(actualValue) 3 | 4 | if valueType == 'table' then 5 | valueType = 'non-callable table' 6 | end 7 | 8 | return 'Failed to validate prop as callable, instead saw ' .. valueType 9 | end 10 | 11 | local function isFunction(toValidate) 12 | return type(toValidate) == 'function' 13 | end 14 | 15 | local function isCallableTable(toValidate) 16 | if type(toValidate) ~= 'table' then 17 | return false 18 | end 19 | 20 | local metatable = getmetatable(toValidate) 21 | 22 | if type(metatable) ~= 'table' then 23 | return false 24 | end 25 | 26 | return isFunction(metatable.__call) 27 | end 28 | 29 | local function callable() 30 | return function(toValidate) 31 | local isValid = isFunction(toValidate) or isCallableTable(toValidate) 32 | local reason = nil 33 | 34 | if not isValid then 35 | reason = getFailureReason(toValidate) 36 | end 37 | 38 | return isValid, reason 39 | end 40 | end 41 | 42 | return callable -------------------------------------------------------------------------------- /src/reactor/propTypes/function.lua: -------------------------------------------------------------------------------- 1 | local function getFailureReason(actualValue) 2 | return 'Failed to validate prop as function, instead saw ' .. type(actualValue) 3 | end 4 | 5 | local function validateFunction() 6 | return function(toValidate) 7 | local isValid = type(toValidate) == 'function' 8 | local reason = nil 9 | 10 | if not isValid then 11 | reason = getFailureReason(toValidate) 12 | end 13 | 14 | return isValid, reason 15 | end 16 | end 17 | 18 | return validateFunction -------------------------------------------------------------------------------- /src/reactor/propTypes/number.lua: -------------------------------------------------------------------------------- 1 | local function getFailureReason(actualValue) 2 | return 'Failed to validate prop as number, instead saw ' .. type(actualValue) 3 | end 4 | 5 | local function validateNumber() 6 | return function(toValidate) 7 | local isValid = type(toValidate) == 'number' 8 | local reason = nil 9 | 10 | if not isValid then 11 | reason = getFailureReason(toValidate) 12 | end 13 | 14 | return isValid, reason 15 | end 16 | end 17 | 18 | return validateNumber -------------------------------------------------------------------------------- /src/reactor/propTypes/oneOf.lua: -------------------------------------------------------------------------------- 1 | local function getFailureReason(actualValue) 2 | return 'Failed to validate prop as oneOf a set of options, ' .. 3 | 'instead saw ' .. type(actualValue) 4 | end 5 | 6 | local function validate(optionsDescription, toValidate) 7 | for _, optionValidator in ipairs(optionsDescription) do 8 | if optionValidator(toValidate) then 9 | return true 10 | end 11 | end 12 | 13 | return false, getFailureReason(toValidate) 14 | end 15 | 16 | local function oneOf(optionsDescription) 17 | assert(type(optionsDescription) == 'table', 18 | 'oneOf validator expected optionsDescription to be expressed as a table') 19 | 20 | return function(toValidate) 21 | return validate(optionsDescription, toValidate) 22 | end 23 | end 24 | 25 | return oneOf -------------------------------------------------------------------------------- /src/reactor/propTypes/optional.lua: -------------------------------------------------------------------------------- 1 | local function optional(validateNormally) 2 | assert(type(validateNormally) == 'function', 3 | 'expected validator supplied to optional to be of type function') 4 | 5 | return function(toValidate) 6 | if toValidate == nil then 7 | return true 8 | else 9 | return validateNormally(toValidate) 10 | end 11 | end 12 | end 13 | 14 | return optional -------------------------------------------------------------------------------- /src/reactor/propTypes/string.lua: -------------------------------------------------------------------------------- 1 | local function getFailureReason(actualValue) 2 | return 'Failed to validate prop as string, instead saw ' .. type(actualValue) 3 | end 4 | 5 | local function validateString() 6 | return function(toValidate) 7 | local isValid = type(toValidate) == 'string' 8 | local reason = nil 9 | 10 | if not isValid then 11 | reason = getFailureReason(toValidate) 12 | end 13 | 14 | return isValid, reason 15 | end 16 | end 17 | 18 | return validateString -------------------------------------------------------------------------------- /src/reactor/propTypes/table.lua: -------------------------------------------------------------------------------- 1 | local function getFailureReason(actualValue) 2 | return 'Failed to validate prop as table, instead saw ' .. type(actualValue) 3 | end 4 | 5 | local function validateTable() 6 | return function(toValidate) 7 | local isValid = type(toValidate) == 'table' 8 | local reason = nil 9 | 10 | if not isValid then 11 | reason = getFailureReason(toValidate) 12 | end 13 | 14 | return isValid, reason 15 | end 16 | end 17 | 18 | return validateTable -------------------------------------------------------------------------------- /src/reactor/propTypes/tableShape.lua: -------------------------------------------------------------------------------- 1 | local getPrintableValue = require('reactor.helpers.getPrintableValue') 2 | 3 | local function getIncorrectKeyCountFailureReason(keyCount) 4 | return 'Failed to validate prop as tableShape, ' .. 5 | 'expected table to have ' .. keyCount .. 'properties' 6 | end 7 | 8 | local function getKeyMismatchFailureReason(expectedKey) 9 | return 'Failed to validate prop as tableShape, ' .. 10 | 'expected table to contain key: ' .. getPrintableValue(expectedKey) 11 | end 12 | 13 | local function getPropertyValidationFailureReason(key, reason) 14 | return 'Failed to validate prop as tableShape, ' .. 15 | 'property validation failed for key: ' .. getPrintableValue(key) .. 16 | '. Validation error: ' .. getPrintableValue(reason) 17 | end 18 | 19 | local function isTableType(toValidate) 20 | local isValid = type(toValidate) == 'table' 21 | local reason = nil 22 | 23 | if not isValid then 24 | reason = 'failed to validate prop as tableShape, ' .. 25 | 'expected table, but saw value of type ' .. type(toValidate) 26 | end 27 | 28 | return isValid, reason 29 | end 30 | 31 | local function getTableKeys(tableWithKeys) 32 | local keys = {} 33 | 34 | for key in pairs(tableWithKeys) do 35 | keys[#keys + 1] = key 36 | end 37 | 38 | table.sort(keys) 39 | 40 | return keys 41 | end 42 | 43 | local function hasAllKeys(toValidate, shapeDescription) 44 | local descritionKeys = getTableKeys(shapeDescription) 45 | local keysToValidate = getTableKeys(toValidate) 46 | 47 | local isValid = #descritionKeys == #keysToValidate 48 | 49 | if not isValid then 50 | return isValid, getIncorrectKeyCountFailureReason(#descritionKeys) 51 | end 52 | 53 | for index, expectedKey in ipairs(descritionKeys) do 54 | local keyToValidate = keysToValidate[index] 55 | 56 | isValid = expectedKey == keyToValidate 57 | 58 | if not isValid then 59 | -- todo: this probably doesn't always return the right key! fix it 60 | return isValid, getKeyMismatchFailureReason(expectedKey) 61 | end 62 | end 63 | 64 | return true 65 | end 66 | 67 | local function hasValidProperties(toValidate, shapeDescription) 68 | for key, validator in pairs(shapeDescription) do 69 | -- todo: check that validator is function 70 | -- or use pcall to catch runtime errors 71 | local propertyToValidate = toValidate[key] 72 | local isValid, reason = validator(propertyToValidate) 73 | 74 | if not isValid then 75 | return isValid, getPropertyValidationFailureReason(key, reason) 76 | end 77 | end 78 | 79 | return true 80 | end 81 | 82 | local function tableShape(shapeDescription) 83 | assert(type(shapeDescription) == 'table', 84 | 'tableShape validator expected shapeDescription to be expressed as a table') 85 | 86 | return function(toValidate) 87 | local isValid, reason = isTableType(toValidate) 88 | 89 | if not isValid then 90 | return isValid, reason 91 | end 92 | 93 | isValid, reason = hasAllKeys(toValidate, shapeDescription) 94 | 95 | if not isValid then 96 | return isValid, reason 97 | end 98 | 99 | isValid, reason = hasValidProperties(toValidate, shapeDescription) 100 | 101 | if not isValid then 102 | return isValid, reason 103 | end 104 | 105 | return isValid 106 | end 107 | end 108 | 109 | return tableShape -------------------------------------------------------------------------------- /src/reactor/propTypes/value.lua: -------------------------------------------------------------------------------- 1 | local getPrintableValue = require('reactor.helpers.getPrintableValue') 2 | 3 | local function getFailureReason(description, actualValue) 4 | local printableDescription = getPrintableValue(description) 5 | local printableValue = getPrintableValue(actualValue) 6 | 7 | return 'Failed to validate prop as value ' .. printableDescription .. 8 | ', instead saw ' .. printableValue 9 | end 10 | 11 | local function value(description) 12 | assert(description ~= nil, 13 | 'value validator expects its argument to be a non-nil value') 14 | 15 | return function(toValidate) 16 | local isValid = toValidate == description 17 | local reason = nil 18 | 19 | if not isValid then 20 | reason = getFailureReason(description, toValidate) 21 | end 22 | 23 | return isValid, reason 24 | end 25 | end 26 | 27 | return value -------------------------------------------------------------------------------- /src/reactor/reconcile.lua: -------------------------------------------------------------------------------- 1 | -- todo 2 | -- * do not modify the list of operations 3 | -- * get operations for children 4 | 5 | local assign = require('reactor.utils.tableUtils').assign 6 | 7 | return function() 8 | local collectionDiff, nodeDiff, diff, recurseThroughCollection 9 | 10 | local function isCollection(tbl) 11 | return type(tbl) == 'table' and 12 | not tbl.name and not tbl.props and 13 | not tbl.parent 14 | end 15 | 16 | local function isParentAndChildren(node) 17 | return type(node) == 'table' and node.parent 18 | end 19 | 20 | local function isNewNode(current, last) 21 | return current and not last 22 | end 23 | 24 | local function isRemovedNode(current, last) 25 | return not current and last 26 | end 27 | 28 | local function push(sourceTable, entry) 29 | sourceTable[#sourceTable + 1] = entry 30 | return sourceTable 31 | end 32 | 33 | local function getIndex(collection, index) 34 | if collection and collection[index] then 35 | return collection[index] 36 | end 37 | 38 | return nil 39 | end 40 | 41 | local function getNode(nodeContainer) 42 | if isParentAndChildren(nodeContainer) then 43 | return nodeContainer.parent 44 | else 45 | return nodeContainer 46 | end 47 | end 48 | 49 | local function getChildren(nodeContainer) 50 | if isParentAndChildren(nodeContainer) then 51 | return nodeContainer.children 52 | end 53 | end 54 | 55 | local function hasSameComponentName(current, last) 56 | return current.name == last.name 57 | end 58 | 59 | local function create(description) 60 | return assign({ 61 | operationType = 'create', 62 | path = description.path, 63 | props = description.props 64 | }, description.operations) 65 | end 66 | 67 | local function update(description) 68 | return assign({ 69 | operationType = 'update', 70 | path = description.path, 71 | props = description.props 72 | }, description.operations) 73 | end 74 | 75 | local function delete(description) 76 | return assign({ 77 | operationType = 'delete', 78 | path = description.path, 79 | props = description.props 80 | }, description.operations) 81 | end 82 | 83 | local function postChildUpdate(description) 84 | return assign({ 85 | operationType = 'postChildUpdate', 86 | path = description.path, 87 | props = description.props 88 | }, description.operations) 89 | end 90 | 91 | collectionDiff = function (current, last, operations) 92 | local isCollectionCurrent = isCollection(current) 93 | local isCollectionLast = isCollection(last) 94 | 95 | if isCollectionCurrent and isCollectionLast then 96 | operations = recurseThroughCollection( 97 | current, 98 | last, 99 | 1, 100 | operations 101 | ) 102 | elseif not isCollectionCurrent and isCollectionLast then 103 | if current then 104 | operations = diff(current, nil, operations) 105 | end 106 | 107 | operations = recurseThroughCollection( 108 | {}, 109 | last, 110 | 1, 111 | operations 112 | ) 113 | elseif isCollectionCurrent and not isCollectionLast then 114 | if last then 115 | operations = diff(nil, last, operations) 116 | end 117 | 118 | operations = recurseThroughCollection( 119 | current, 120 | {}, 121 | 1, 122 | operations 123 | ) 124 | end 125 | 126 | return operations 127 | end 128 | 129 | nodeDiff = function(current, last, operations) 130 | local currentNode = getNode(current) 131 | local lastNode = getNode(last) 132 | 133 | if isNewNode(currentNode, lastNode) then 134 | push(operations, create(currentNode)) 135 | elseif isRemovedNode(currentNode, lastNode) then 136 | push(operations, delete(lastNode)) 137 | elseif hasSameComponentName(currentNode, lastNode) then 138 | push(operations, update(currentNode)) 139 | else 140 | push(operations, delete(lastNode)) 141 | push(operations, create(currentNode)) 142 | end 143 | 144 | local currentChildren = getChildren(current) 145 | local lastChildren = getChildren(last) 146 | 147 | if currentChildren or lastChildren then 148 | operations = diff(currentChildren, lastChildren, operations) 149 | end 150 | 151 | if currentNode and currentChildren then 152 | push(operations, postChildUpdate(currentNode)) 153 | end 154 | 155 | return operations 156 | end 157 | 158 | recurseThroughCollection = function(current, last, currentIndex, operations) 159 | local currentNode = getIndex(current, currentIndex) 160 | local lastNode = getIndex(last, currentIndex) 161 | 162 | if not currentNode and not lastNode then 163 | return operations 164 | end 165 | 166 | -- get the operations for the current node 167 | operations = diff( 168 | currentNode, 169 | lastNode, 170 | operations 171 | ) 172 | 173 | -- recurse to the next node 174 | return recurseThroughCollection( 175 | current, 176 | last, 177 | currentIndex + 1, 178 | operations 179 | ) 180 | end 181 | 182 | diff = function(current, last, operations) 183 | if isCollection(current) or isCollection(last) then 184 | operations = collectionDiff(current, last, operations) 185 | else 186 | operations = nodeDiff(current, last, operations) 187 | end 188 | return operations 189 | end 190 | 191 | return function(current, last) 192 | assert(type(current) == 'table', 193 | 'reconcile expects a table as its first argument') 194 | assert(last == nil or type(last) == 'table', 195 | 'reconcile expects a table or nil as its second argument') 196 | return diff(current, last, {}) 197 | end 198 | end -------------------------------------------------------------------------------- /src/reactor/registry.lua: -------------------------------------------------------------------------------- 1 | local function registryModule() 2 | local mounted = {} 3 | 4 | local function getRegistryData(path) 5 | return mounted[path] 6 | end 7 | 8 | local function isComponentMounted(path) 9 | return not not getRegistryData(path) 10 | end 11 | 12 | local function getLastProps(path) 13 | if isComponentMounted(path) then 14 | return getRegistryData(path).lastProps 15 | end 16 | end 17 | 18 | local function mountComponentIfRequired(component, props, path) 19 | if not isComponentMounted(path) then 20 | mounted[path] = { 21 | component = component, 22 | lastProps = props 23 | } 24 | end 25 | end 26 | 27 | return { 28 | isComponentMounted = isComponentMounted, 29 | getLastProps = getLastProps, 30 | mountComponentIfRequired = mountComponentIfRequired 31 | } 32 | end 33 | 34 | return registryModule -------------------------------------------------------------------------------- /src/reactor/utils/funcUtils.lua: -------------------------------------------------------------------------------- 1 | local tableUtils = require('reactor.utils.tableUtils') 2 | local reduce = tableUtils.reduce 3 | local concat = tableUtils.concat 4 | local map = tableUtils.map 5 | local curryPlaceholder = {} 6 | 7 | local function compose(originalSource, ...) 8 | return reduce(function(func, _, source) 9 | return func(source) 10 | end, originalSource, {...}) 11 | end 12 | 13 | local function replacePlaceholder(args) 14 | return map(function(value) 15 | if value == curryPlaceholder then 16 | return nil 17 | else 18 | return value 19 | end 20 | end, args) 21 | end 22 | 23 | local function curry(func, requiredArity, previousArgs) 24 | return function(...) 25 | previousArgs = previousArgs or {} 26 | local newArgs 27 | 28 | if ... then 29 | newArgs = {...} 30 | else 31 | newArgs = {curryPlaceholder} 32 | end 33 | 34 | local arguments = concat(previousArgs, newArgs) 35 | local arity = #arguments 36 | 37 | if arity >= requiredArity then 38 | local args = replacePlaceholder(arguments) 39 | return func(unpack(args, 1, table.maxn(args))) 40 | else 41 | return curry(func, requiredArity, arguments) 42 | end 43 | end 44 | end 45 | 46 | return { 47 | compose = compose, 48 | curry = curry 49 | } -------------------------------------------------------------------------------- /src/reactor/utils/logUtils.lua: -------------------------------------------------------------------------------- 1 | local logString, logBoolean, logKey, logTable, getPrintableValue 2 | 3 | local function getPadding(amount) 4 | return string.rep(' ', amount) 5 | end 6 | 7 | logString = function(value) 8 | return "'" .. value .. "'" 9 | end 10 | 11 | logBoolean = function(bool) 12 | return bool and 'true' or 'false' 13 | end 14 | 15 | logKey = function(key, value) 16 | return key .. ' = ' .. value 17 | end 18 | 19 | logTable = function(tbl, pad) 20 | local outerPadding = getPadding(pad) 21 | local innerPadding = getPadding(pad + 2) 22 | 23 | local tblValues = {} 24 | for key, value in pairs(tbl) do 25 | tblValues[#tblValues + 1] = innerPadding .. 26 | logKey(key, getPrintableValue(value, pad + 2)) 27 | end 28 | 29 | return '{\n' .. table.concat(tblValues, ',\n') .. '\n' .. outerPadding .. '}' 30 | end 31 | 32 | getPrintableValue = function(value, pad) 33 | local output = '' 34 | 35 | if not pad then 36 | pad = 0 37 | end 38 | 39 | if value == nil then 40 | output = output .. '' 41 | elseif type(value) == 'string' then 42 | output = output .. logString(value) 43 | elseif type(value) == 'number' then 44 | output = output .. value 45 | elseif type(value) == 'boolean' then 46 | output = output .. logBoolean(value) 47 | elseif type(value) == 'function' then 48 | output = output .. '' 49 | elseif type(value) == 'table' then 50 | output = output .. logTable(value, pad) 51 | else 52 | output = output .. '<' .. type(value) .. '>' 53 | end 54 | 55 | return output 56 | end 57 | 58 | local function printValue(value) 59 | print(getPrintableValue(value)) 60 | end 61 | 62 | return { 63 | getPrintableValue = getPrintableValue, 64 | printValue = printValue 65 | } -------------------------------------------------------------------------------- /src/reactor/utils/tableUtils.lua: -------------------------------------------------------------------------------- 1 | local function map(func, collection) 2 | local results = {} 3 | 4 | for index, value in pairs(collection) do 5 | local newValue, newIndex = func(value, index) 6 | results[newIndex or index] = newValue 7 | end 8 | 9 | return results 10 | end 11 | 12 | local function optionalMap(func, collection) 13 | local results = {} 14 | 15 | for index, value in pairs(collection) do 16 | local newValue, newIndex = func(value, index) 17 | if newValue then 18 | results[newIndex or index] = newValue 19 | end 20 | end 21 | 22 | return results 23 | end 24 | 25 | local function reduce(func, accum, collection) 26 | for index, value in pairs(collection) do 27 | accum = func(value, index, accum) 28 | end 29 | 30 | return accum 31 | end 32 | 33 | local function filter(func, collection) 34 | local results = {} 35 | 36 | for index, value in pairs(collection) do 37 | if func(value, index) then 38 | results[index] = value 39 | end 40 | end 41 | 42 | return results 43 | end 44 | 45 | local function assignOne(targetCollection, sourceCollection) 46 | for index, value in pairs(sourceCollection) do 47 | targetCollection[index] = value 48 | end 49 | return targetCollection 50 | end 51 | 52 | local function assign(targetCollection, ...) 53 | local sourceCollections = {...} 54 | for _, sourceCollection in ipairs(sourceCollections) do 55 | targetCollection = assignOne(targetCollection, sourceCollection) 56 | end 57 | return targetCollection 58 | end 59 | 60 | local function first(collection) 61 | return collection[1] 62 | end 63 | 64 | local function last(collection) 65 | return collection[#collection] 66 | end 67 | 68 | local function rest(collection) 69 | if #collection < 2 then 70 | return nil 71 | end 72 | 73 | local results = {} 74 | 75 | for i = 2, #collection do 76 | results[i - 1] = collection[i] 77 | end 78 | 79 | return results 80 | end 81 | 82 | local function concat(...) 83 | local result = {} 84 | 85 | for _, tbl in ipairs{...} do 86 | for _, val in pairs(tbl) do 87 | result[#result + 1] = val 88 | end 89 | end 90 | 91 | return result 92 | end 93 | 94 | return { 95 | map = map, 96 | optionalMap = optionalMap, 97 | reduce = reduce, 98 | filter = filter, 99 | assign = assign, 100 | first = first, 101 | last = last, 102 | rest = rest, 103 | concat = concat 104 | } -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | luacheck src/ 3 | lua tests/runner.lua -------------------------------------------------------------------------------- /tests/.luacheckrc: -------------------------------------------------------------------------------- 1 | globals = {"describe", "it", "expect", "spy"} 2 | max_line_length = false -------------------------------------------------------------------------------- /tests/libs/lust.lua: -------------------------------------------------------------------------------- 1 | -- lust v0.1.0 - Lua test framework 2 | -- https://github.com/bjornbytes/lust 3 | -- MIT LICENSE 4 | 5 | local lust = {} 6 | lust.level = 0 7 | lust.passes = 0 8 | lust.errors = 0 9 | lust.befores = {} 10 | lust.afters = {} 11 | 12 | local red = string.char(27) .. '[31m' 13 | local green = string.char(27) .. '[32m' 14 | local normal = string.char(27) .. '[0m' 15 | local function indent(level) return string.rep('\t', level or lust.level) end 16 | 17 | function lust.describe(name, fn) 18 | print(indent() .. name) 19 | lust.level = lust.level + 1 20 | fn() 21 | lust.befores[lust.level] = {} 22 | lust.afters[lust.level] = {} 23 | lust.level = lust.level - 1 24 | end 25 | 26 | function lust.it(name, fn) 27 | for level = 1, lust.level do 28 | if lust.befores[level] then 29 | for i = 1, #lust.befores[level] do 30 | lust.befores[level][i](name) 31 | end 32 | end 33 | end 34 | 35 | local success, err = pcall(fn) 36 | if success then lust.passes = lust.passes + 1 37 | else lust.errors = lust.errors + 1 end 38 | local color = success and green or red 39 | local label = success and 'PASS' or 'FAIL' 40 | print(indent() .. color .. label .. normal .. ' ' .. name) 41 | if err then 42 | print(indent(lust.level + 1) .. red .. err .. normal) 43 | end 44 | 45 | for level = 1, lust.level do 46 | if lust.afters[level] then 47 | for i = 1, #lust.afters[level] do 48 | lust.afters[level][i](name) 49 | end 50 | end 51 | end 52 | end 53 | 54 | function lust.before(fn) 55 | lust.befores[lust.level] = lust.befores[lust.level] or {} 56 | table.insert(lust.befores[lust.level], fn) 57 | end 58 | 59 | function lust.after(fn) 60 | lust.afters[lust.level] = lust.afters[lust.level] or {} 61 | table.insert(lust.afters[lust.level], fn) 62 | end 63 | 64 | -- Assertions 65 | local function isa(v, x) 66 | if type(x) == 'string' then 67 | return type(v) == x, 68 | 'expected ' .. tostring(v) .. ' to be a ' .. x, 69 | 'expected ' .. tostring(v) .. ' to not be a ' .. x 70 | elseif type(x) == 'table' then 71 | if type(v) ~= 'table' then 72 | return false, 73 | 'expected ' .. tostring(v) .. ' to be a ' .. tostring(x), 74 | 'expected ' .. tostring(v) .. ' to not be a ' .. tostring(x) 75 | end 76 | 77 | local seen = {} 78 | local meta = v 79 | while meta and not seen[meta] do 80 | if meta == x then return true end 81 | seen[meta] = true 82 | meta = getmetatable(meta) and getmetatable(meta).__index 83 | end 84 | 85 | return false, 86 | 'expected ' .. tostring(v) .. ' to be a ' .. tostring(x), 87 | 'expected ' .. tostring(v) .. ' to not be a ' .. tostring(x) 88 | end 89 | 90 | error('invalid type ' .. tostring(x)) 91 | end 92 | 93 | local function has(t, x) 94 | for k, v in pairs(t) do 95 | if v == x then return true end 96 | end 97 | return false 98 | end 99 | 100 | local function strict_eq(t1, t2) 101 | if type(t1) ~= type(t2) then return false end 102 | if type(t1) ~= 'table' then return t1 == t2 end 103 | if #t1 ~= #t2 then return false end 104 | for k, _ in pairs(t1) do 105 | if not strict_eq(t1[k], t2[k]) then return false end 106 | end 107 | for k, _ in pairs(t2) do 108 | if not strict_eq(t2[k], t1[k]) then return false end 109 | end 110 | return true 111 | end 112 | 113 | local paths = { 114 | [''] = { 'to', 'to_not' }, 115 | to = { 'have', 'equal', 'be', 'exist', 'fail' }, 116 | to_not = { 'have', 'equal', 'be', 'exist', 'fail', chain = function(a) a.negate = not a.negate end }, 117 | a = { test = isa }, 118 | an = { test = isa }, 119 | be = { 'a', 'an', 'truthy', 120 | test = function(v, x) 121 | return v == x, 122 | 'expected ' .. tostring(v) .. ' and ' .. tostring(x) .. ' to be equal', 123 | 'expected ' .. tostring(v) .. ' and ' .. tostring(x) .. ' to not be equal' 124 | end 125 | }, 126 | exist = { 127 | test = function(v) 128 | return v ~= nil, 129 | 'expected ' .. tostring(v) .. ' to exist', 130 | 'expected ' .. tostring(v) .. ' to not exist' 131 | end 132 | }, 133 | truthy = { 134 | test = function(v) 135 | return v, 136 | 'expected ' .. tostring(v) .. ' to be truthy', 137 | 'expected ' .. tostring(v) .. ' to not be truthy' 138 | end 139 | }, 140 | equal = { 141 | test = function(v, x) 142 | return strict_eq(v, x), 143 | 'expected ' .. tostring(v) .. ' and ' .. tostring(x) .. ' to be exactly equal', 144 | 'expected ' .. tostring(v) .. ' and ' .. tostring(x) .. ' to not be exactly equal' 145 | end 146 | }, 147 | have = { 148 | test = function(v, x) 149 | if type(v) ~= 'table' then 150 | error('expected ' .. tostring(v) .. ' to be a table') 151 | end 152 | 153 | return has(v, x), 154 | 'expected ' .. tostring(v) .. ' to contain ' .. tostring(x), 155 | 'expected ' .. tostring(v) .. ' to not contain ' .. tostring(x) 156 | end 157 | }, 158 | fail = { 159 | test = function(v) 160 | return not pcall(v), 161 | 'expected ' .. tostring(v) .. ' to fail', 162 | 'expected ' .. tostring(v) .. ' to not fail' 163 | end 164 | } 165 | } 166 | 167 | function lust.expect(v) 168 | local assertion = {} 169 | assertion.val = v 170 | assertion.action = '' 171 | assertion.negate = false 172 | 173 | setmetatable(assertion, { 174 | __index = function(t, k) 175 | if has(paths[rawget(t, 'action')], k) then 176 | rawset(t, 'action', k) 177 | local chain = paths[rawget(t, 'action')].chain 178 | if chain then chain(t) end 179 | return t 180 | end 181 | return rawget(t, k) 182 | end, 183 | __call = function(t, ...) 184 | if paths[t.action].test then 185 | local res, err, nerr = paths[t.action].test(t.val, ...) 186 | if assertion.negate then 187 | res = not res 188 | err = nerr or err 189 | end 190 | if not res then 191 | error(err or 'unknown failure', 2) 192 | end 193 | end 194 | end 195 | }) 196 | 197 | return assertion 198 | end 199 | 200 | function lust.spy(target, name, run) 201 | local spy = {} 202 | local subject 203 | 204 | local function capture(...) 205 | table.insert(spy, {...}) 206 | return subject(...) 207 | end 208 | 209 | if type(target) == 'table' then 210 | subject = target[name] 211 | target[name] = capture 212 | else 213 | run = name 214 | subject = target or function() end 215 | end 216 | 217 | setmetatable(spy, {__call = function(_, ...) return capture(...) end}) 218 | 219 | if run then run() end 220 | 221 | return spy 222 | end 223 | 224 | lust.test = lust.it 225 | lust.paths = paths 226 | 227 | return lust -------------------------------------------------------------------------------- /tests/runner.lua: -------------------------------------------------------------------------------- 1 | lust = require('tests.libs.lust') 2 | local files = require('tests.suite') 3 | 4 | for _, fn in pairs({'describe', 'it', 'test', 'expect', 'spy', 'before', 'after'}) do 5 | _G[fn] = lust[fn] 6 | end 7 | 8 | for i, path in ipairs(files) do 9 | dofile(path) 10 | if next(files, i) then 11 | print() 12 | end 13 | end 14 | 15 | local red = string.char(27) .. '[31m' 16 | local green = string.char(27) .. '[32m' 17 | local normal = string.char(27) .. '[0m' 18 | 19 | if lust.errors > 0 then 20 | io.write(red .. lust.errors .. normal .. ' failed, ') 21 | end 22 | 23 | print(green .. lust.passes .. normal .. ' passed') 24 | 25 | if lust.errors > 0 then os.exit(1) end -------------------------------------------------------------------------------- /tests/specs/reactor/component.lua: -------------------------------------------------------------------------------- 1 | local componentModule = require('reactor.component') 2 | local expectedLifecycle = 'test-lifecycle' 3 | local mockReactor = { 4 | lifecycle = function() 5 | return expectedLifecycle 6 | end 7 | } 8 | local component = componentModule(mockReactor) 9 | 10 | describe('#component', function() 11 | describe('errors', function() 12 | it('causes an error if the component definition does not have a `name` property', function() 13 | local componentWithoutName = { 14 | render = function() end 15 | } 16 | 17 | expect(function() component(componentWithoutName) end) 18 | .to.fail() 19 | end) 20 | 21 | it('causes an error if the component definition does not have a `render` property', function() 22 | local componentWithoutRender = { 23 | name = 'test' 24 | } 25 | 26 | expect(function() component(componentWithoutRender) end) 27 | .to.fail() 28 | end) 29 | end) 30 | 31 | describe('behaviour', function() 32 | it('returns the result of the lifecycle function', function() 33 | local output = component{ 34 | name = 'test', 35 | render = function() end 36 | } 37 | 38 | expect(output) 39 | .to.be(expectedLifecycle) 40 | end) 41 | end) 42 | end) -------------------------------------------------------------------------------- /tests/specs/reactor/invoke.lua: -------------------------------------------------------------------------------- 1 | local invokeModule = require('reactor.invoke') 2 | 3 | local invoke = invokeModule() 4 | 5 | describe('invoke', function() 6 | describe('create operation type', function() 7 | local expectedProps = {} 8 | local operations = { 9 | { 10 | operationType = 'create', 11 | create = function() end, 12 | update = function() end, 13 | draw = function() end, 14 | props = expectedProps 15 | } 16 | } 17 | 18 | it('calls the create, update and draw functions in the operation with the props', function() 19 | local operation = operations[1] 20 | local createSpy = spy(operation, 'create') 21 | local updateSpy = spy(operation, 'update') 22 | local drawSpy = spy(operation, 'draw') 23 | 24 | invoke(operations) 25 | 26 | expect(#createSpy) 27 | .to.be(1) 28 | expect(createSpy[1][1]) 29 | .to.be(expectedProps) 30 | 31 | expect(#updateSpy) 32 | .to.be(1) 33 | expect(updateSpy[1][2]) 34 | .to.be(expectedProps) 35 | 36 | expect(#drawSpy) 37 | .to.be(1) 38 | expect(drawSpy[1][1]) 39 | .to.be(expectedProps) 40 | end) 41 | end) 42 | 43 | describe('update operation type', function() 44 | local expectedProps = {} 45 | local operations = { 46 | { 47 | operationType = 'update', 48 | update = function() end, 49 | draw = function() end, 50 | props = expectedProps 51 | } 52 | } 53 | 54 | it('calls the update and draw functions in the operation with the props', function() 55 | local operation = operations[1] 56 | local updateSpy = spy(operation, 'update') 57 | local drawSpy = spy(operation, 'draw') 58 | 59 | invoke(operations) 60 | 61 | expect(#updateSpy) 62 | .to.be(1) 63 | expect(updateSpy[1][2]) 64 | .to.be(expectedProps) 65 | 66 | expect(#drawSpy) 67 | .to.be(1) 68 | expect(drawSpy[1][1]) 69 | .to.be(expectedProps) 70 | end) 71 | end) 72 | 73 | describe('delete operation type', function() 74 | local expectedProps = {} 75 | local operations = { 76 | { 77 | operationType = 'delete', 78 | delete = function() end, 79 | props = expectedProps 80 | } 81 | } 82 | 83 | it('calls the update and draw functions in the operation with the props', function() 84 | local operation = operations[1] 85 | local deleteSpy = spy(operation, 'delete') 86 | 87 | invoke(operations) 88 | 89 | expect(#deleteSpy) 90 | .to.be(1) 91 | expect(deleteSpy[1][1]) 92 | .to.be(expectedProps) 93 | end) 94 | end) 95 | 96 | describe('postChildUpdate operation type', function() 97 | local expectedProps = {} 98 | local operations = { 99 | { 100 | operationType = 'postChildUpdate', 101 | postChildUpdate = function() end, 102 | props = expectedProps 103 | } 104 | } 105 | 106 | it('calls the update and draw functions in the operation with the props', function() 107 | local operation = operations[1] 108 | local postChildUpdateSpy = spy(operation, 'postChildUpdate') 109 | 110 | invoke(operations) 111 | 112 | expect(#postChildUpdateSpy) 113 | .to.be(1) 114 | expect(postChildUpdateSpy[1][1]) 115 | .to.be(expectedProps) 116 | end) 117 | end) 118 | end) -------------------------------------------------------------------------------- /tests/specs/reactor/lifecycle.lua: -------------------------------------------------------------------------------- 1 | local lifecycleModule = require('reactor.lifecycle') 2 | local mockReactor = { 3 | registry = { 4 | getLastProps = function() end, 5 | mountComponentIfRequired = function() end 6 | } 7 | } 8 | local mockComponent = { 9 | name = 'mock', 10 | render = function(props) 11 | return function(path) 12 | return { 13 | props = props, 14 | path = path 15 | } 16 | end 17 | end 18 | } 19 | local lifecycle = lifecycleModule(mockReactor) 20 | 21 | describe('#lifecycle', function() 22 | describe('behaviour', function() 23 | it('returns a function when called with a `component`', function() 24 | local component = {} 25 | 26 | expect(type(lifecycle(component))) 27 | .to.be('function') 28 | end) 29 | 30 | it('returns a function when called with a `component` and `props`', function() 31 | local component = {} 32 | local props = {} 33 | 34 | expect(type(lifecycle(component, props))) 35 | .to.be('function') 36 | end) 37 | 38 | it('returns a description of the rendered components when called with a `component`, `props`, and a `path`', function() 39 | local props = { 40 | test = 'test-value' 41 | } 42 | local path = '' 43 | 44 | local output = lifecycle(mockComponent, props, path) 45 | 46 | expect(output.path) 47 | .to.be('mock') 48 | 49 | expect(output.props.test) 50 | .to.be('test-value') 51 | end) 52 | end) 53 | end) -------------------------------------------------------------------------------- /tests/specs/reactor/propTypes/boolean.lua: -------------------------------------------------------------------------------- 1 | local validateBoolean = require('reactor.propTypes.boolean') 2 | 3 | describe('boolean', function() 4 | describe('behaviour', function() 5 | it('returns a validator function to use for validation', function() 6 | expect(type(validateBoolean())) 7 | .to.be('function') 8 | end) 9 | 10 | it('returns true if the supplied value to the validator is of type boolean', function() 11 | local validator = validateBoolean() 12 | 13 | expect(validator(true)) 14 | .to.be(true) 15 | 16 | expect(validator(false)) 17 | .to.be(true) 18 | end) 19 | 20 | it('does not return a second return value when validation is successful', function() 21 | local validator = validateBoolean() 22 | local isValid, reason = validator(true) 23 | 24 | expect(reason) 25 | .to.be(nil) 26 | end) 27 | 28 | it('returns false if the supplied value to the validator is of a type that is not a boolean', function() 29 | local validator = validateBoolean() 30 | 31 | expect(validator(nil)) 32 | .to.be(false) 33 | 34 | expect(validator(12)) 35 | .to.be(false) 36 | 37 | expect(validator('test')) 38 | .to.be(false) 39 | 40 | expect(validator(function() end)) 41 | .to.be(false) 42 | 43 | expect(validator({})) 44 | .to.be(false) 45 | end) 46 | 47 | it('returns a second return value of type string that represents the reason validation failed', function() 48 | local validator = validateBoolean() 49 | local isValid, reason = validator(nil) 50 | 51 | expect(type(reason)) 52 | .to.be('string') 53 | end) 54 | end) 55 | end) -------------------------------------------------------------------------------- /tests/specs/reactor/propTypes/callable.lua: -------------------------------------------------------------------------------- 1 | local callable = require('reactor.propTypes.callable') 2 | 3 | describe('callable', function() 4 | describe('behaviour', function() 5 | it('returns a function to use for validation', function() 6 | expect(type(callable())) 7 | .to.be('function') 8 | end) 9 | 10 | it('returns true when a function is passed to the validator', function() 11 | local callableValidator = callable() 12 | 13 | expect(callableValidator(function() end)) 14 | .to.be(true) 15 | end) 16 | 17 | it('returns true when a callable table is passed to the validator', function() 18 | local callableValidator = callable() 19 | local callableTable = {} 20 | setmetatable(callableTable, { 21 | __call = function() end 22 | }) 23 | 24 | expect(callableValidator(callableTable)) 25 | .to.be(true) 26 | end) 27 | 28 | it('does not return a second return value when validating a function is successful', function() 29 | local validator = callable() 30 | local isValid, reason = validator(function() end) 31 | 32 | expect(reason) 33 | .to.be(nil) 34 | end) 35 | 36 | it('does not return a second return value when validating a callable table is successful', function() 37 | local validator = callable() 38 | local callableTable = {} 39 | setmetatable(callableTable, { 40 | __call = function() end 41 | }) 42 | local isValid, reason = validator(callableTable) 43 | 44 | expect(reason) 45 | .to.be(nil) 46 | end) 47 | 48 | it('returns false when a non-function is passed to the validator', function() 49 | local callableValidator = callable() 50 | 51 | expect(callableValidator('test')) 52 | .to.be(false) 53 | 54 | expect(callableValidator(12)) 55 | .to.be(false) 56 | 57 | expect(callableValidator(nil)) 58 | .to.be(false) 59 | end) 60 | 61 | it('returns false when a non-callable table is passed to the validator', function() 62 | local callableValidator = callable() 63 | 64 | expect(callableValidator({})) 65 | .to.be(false) 66 | end) 67 | 68 | it('returns a second return value of type string that represents the reason validation failed', function() 69 | local validator = callable() 70 | local isValid, reason = validator(nil) 71 | 72 | expect(type(reason)) 73 | .to.be('string') 74 | end) 75 | end) 76 | end) -------------------------------------------------------------------------------- /tests/specs/reactor/propTypes/function.lua: -------------------------------------------------------------------------------- 1 | local validateFunction = require('reactor.propTypes.function') 2 | 3 | describe('function', function() 4 | describe('behaviour', function() 5 | it('returns a function to use for validation', function() 6 | expect(type(validateFunction())) 7 | .to.be('function') 8 | end) 9 | 10 | it('returns true when a function is passed to the validator', function() 11 | local functionValidator = validateFunction() 12 | 13 | expect(functionValidator(function() end)) 14 | .to.be(true) 15 | end) 16 | 17 | it('does not return a second return value when validation is successful', function() 18 | local validator = validateFunction() 19 | local isValid, reason = validator(function() end) 20 | 21 | expect(reason) 22 | .to.be(nil) 23 | end) 24 | 25 | it('returns false when a non-function is passed to the validator', function() 26 | local functionValidator = validateFunction() 27 | 28 | expect(functionValidator('test')) 29 | .to.be(false) 30 | 31 | expect(functionValidator(12)) 32 | .to.be(false) 33 | 34 | expect(functionValidator(nil)) 35 | .to.be(false) 36 | 37 | expect(functionValidator({})) 38 | .to.be(false) 39 | end) 40 | 41 | it('returns a second return value of type string that represents the reason validation failed', function() 42 | local validator = validateFunction() 43 | local isValid, reason = validator(nil) 44 | 45 | expect(type(reason)) 46 | .to.be('string') 47 | end) 48 | end) 49 | end) -------------------------------------------------------------------------------- /tests/specs/reactor/propTypes/number.lua: -------------------------------------------------------------------------------- 1 | local validateNumber = require('reactor.propTypes.number') 2 | 3 | describe('number', function() 4 | describe('behaviour', function() 5 | it('returns a validator function to use for validation', function() 6 | expect(type(validateNumber())) 7 | .to.be('function') 8 | end) 9 | 10 | it('returns true if the supplied value to the validator is of type number', function() 11 | local validator = validateNumber() 12 | 13 | expect(validator(12)) 14 | .to.be(true) 15 | end) 16 | 17 | it('does not return a second return value when validation is successful', function() 18 | local validator = validateNumber() 19 | local isValid, reason = validator(12) 20 | 21 | expect(reason) 22 | .to.be(nil) 23 | end) 24 | 25 | it('returns false if the supplied value to the validator is of a type that is not a number', function() 26 | local validator = validateNumber() 27 | 28 | expect(validator(nil)) 29 | .to.be(false) 30 | 31 | expect(validator(true)) 32 | .to.be(false) 33 | 34 | expect(validator('test')) 35 | .to.be(false) 36 | 37 | expect(validator(function() end)) 38 | .to.be(false) 39 | 40 | expect(validator({})) 41 | .to.be(false) 42 | end) 43 | 44 | it('returns a second return value of type string that represents the reason validation failed', function() 45 | local validator = validateNumber() 46 | local isValid, reason = validator(nil) 47 | 48 | expect(type(reason)) 49 | .to.be('string') 50 | end) 51 | end) 52 | end) -------------------------------------------------------------------------------- /tests/specs/reactor/propTypes/oneOf.lua: -------------------------------------------------------------------------------- 1 | local oneOf = require('reactor.propTypes.oneOf') 2 | 3 | local function trueValidator() 4 | return true 5 | end 6 | 7 | local function falseValidator() 8 | return false 9 | end 10 | 11 | describe('oneOf', function() 12 | describe('error states', function() 13 | it('causes an error if the description passed in not of type table', function() 14 | expect(function() 15 | oneOf() 16 | end).to.fail() 17 | 18 | expect(function() 19 | oneOf('test') 20 | end).to.fail() 21 | 22 | expect(function() 23 | oneOf(12) 24 | end).to.fail() 25 | 26 | expect(function() 27 | oneOf(function() end) 28 | end).to.fail() 29 | 30 | expect(function() 31 | oneOf(false) 32 | end).to.fail() 33 | end) 34 | end) 35 | 36 | describe('behaviour', function() 37 | it('returns a function to use for validation when passed a valid description', function() 38 | expect(type(oneOf({trueValidator, falseValidator}))) 39 | .to.be('function') 40 | end) 41 | 42 | it('returns false if all validators specified in the description return false', function() 43 | local validator = oneOf({ 44 | falseValidator, 45 | falseValidator 46 | }) 47 | 48 | expect(validator()) 49 | .to.be(false) 50 | end) 51 | 52 | it('returns true if a single validator specified in the description return true', function() 53 | local validator = oneOf({ 54 | falseValidator, 55 | trueValidator 56 | }) 57 | 58 | expect(validator()) 59 | .to.be(true) 60 | end) 61 | 62 | it('returns true if a all validators specified in the description return true', function() 63 | local validator = oneOf({ 64 | trueValidator, 65 | trueValidator 66 | }) 67 | 68 | expect(validator()) 69 | .to.be(true) 70 | end) 71 | 72 | it('does not return a second return value when validation is successful', function() 73 | local validator = oneOf({ 74 | trueValidator, 75 | trueValidator 76 | }) 77 | local isValid, reason = validator() 78 | 79 | expect(reason) 80 | .to.be(nil) 81 | end) 82 | 83 | 84 | it('returns a second return value of type string that represents the reason validation failed', function() 85 | local validator = oneOf({ 86 | falseValidator, 87 | falseValidator 88 | }) 89 | local isValid, reason = validator(nil) 90 | 91 | expect(type(reason)) 92 | .to.be('string') 93 | end) 94 | end) 95 | end) -------------------------------------------------------------------------------- /tests/specs/reactor/propTypes/optional.lua: -------------------------------------------------------------------------------- 1 | local optional = require('reactor.propTypes.optional') 2 | 3 | local function stringValidator() 4 | return function(toValidate) 5 | local isValid = type(toValidate) == 'string' 6 | local reason = nil 7 | 8 | if not isValid then 9 | reason = 'not a string' 10 | end 11 | 12 | return isValid, reason 13 | end 14 | end 15 | 16 | describe('optional', function() 17 | describe('error states', function() 18 | it('causes an error if the argument to optional is not a function', function() 19 | expect(function() 20 | optional() 21 | end).to.fail() 22 | 23 | expect(function() 24 | optional('test') 25 | end).to.fail() 26 | 27 | expect(function() 28 | optional(12) 29 | end).to.fail() 30 | 31 | expect(function() 32 | optional(true) 33 | end).to.fail() 34 | 35 | expect(function() 36 | optional({}) 37 | end).to.fail() 38 | end) 39 | end) 40 | 41 | describe('behaviour', function() 42 | it('returns a function to use for validation when passed a function to wrap', function() 43 | expect(type(optional(stringValidator()))) 44 | .to.be('function') 45 | end) 46 | 47 | it('allows nil as an accepted value when wrapping another validator', function() 48 | local validateString = stringValidator() 49 | local optionalValidateString = optional(stringValidator()) 50 | 51 | expect(validateString('test')) 52 | .to.be(true) 53 | 54 | expect(validateString(nil)) 55 | .to.be(false) 56 | 57 | expect(validateString(12)) 58 | .to.be(false) 59 | 60 | expect(optionalValidateString('test')) 61 | .to.be(true) 62 | 63 | expect(optionalValidateString(nil)) 64 | .to.be(true) 65 | 66 | expect(optionalValidateString(12)) 67 | .to.be(false) 68 | end) 69 | 70 | it('does not return a second return value when validation is successful', function() 71 | local validator = optional(stringValidator()) 72 | local isValid, reason = validator() 73 | 74 | expect(reason) 75 | .to.be(nil) 76 | end) 77 | 78 | 79 | it('returns a second return value of type string that represents the reason validation failed', function() 80 | local validator = optional(stringValidator()) 81 | local isValid, reason = validator(12) 82 | 83 | expect(type(reason)) 84 | .to.be('string') 85 | end) 86 | end) 87 | end) -------------------------------------------------------------------------------- /tests/specs/reactor/propTypes/string.lua: -------------------------------------------------------------------------------- 1 | local validateString = require('reactor.propTypes.string') 2 | 3 | describe('string', function() 4 | describe('behaviour', function() 5 | it('returns a validator function to use for validation', function() 6 | expect(type(validateString())) 7 | .to.be('function') 8 | end) 9 | 10 | it('returns true if the supplied value to the validator is of type string', function() 11 | local validator = validateString() 12 | 13 | expect(validator('test')) 14 | .to.be(true) 15 | end) 16 | 17 | it('does not return a second return value when validation is successful', function() 18 | local validator = validateString() 19 | local isValid, reason = validator('test') 20 | 21 | expect(reason) 22 | .to.be(nil) 23 | end) 24 | 25 | it('returns false if the supplied value to the validator is of a type that is not a string', function() 26 | local validator = validateString() 27 | 28 | expect(validator(nil)) 29 | .to.be(false) 30 | 31 | expect(validator(true)) 32 | .to.be(false) 33 | 34 | expect(validator(12)) 35 | .to.be(false) 36 | 37 | expect(validator(function() end)) 38 | .to.be(false) 39 | 40 | expect(validator({})) 41 | .to.be(false) 42 | end) 43 | 44 | it('returns a second return value of type string that represents the reason validation failed', function() 45 | local validator = validateString() 46 | local isValid, reason = validator(nil) 47 | 48 | expect(type(reason)) 49 | .to.be('string') 50 | end) 51 | end) 52 | end) -------------------------------------------------------------------------------- /tests/specs/reactor/propTypes/table.lua: -------------------------------------------------------------------------------- 1 | local validateTable = require('reactor.propTypes.table') 2 | 3 | describe('table', function() 4 | describe('behaviour', function() 5 | it('returns a validator function to use for validation', function() 6 | expect(type(validateTable())) 7 | .to.be('function') 8 | end) 9 | 10 | it('returns true if the supplied value to the validator is of type table', function() 11 | local validator = validateTable() 12 | 13 | expect(validator({})) 14 | .to.be(true) 15 | end) 16 | 17 | it('does not return a second return value when validation is successful', function() 18 | local validator = validateTable() 19 | local isValid, reason = validator({}) 20 | 21 | expect(reason) 22 | .to.be(nil) 23 | end) 24 | 25 | it('returns false if the supplied value to the validator is of a type that is not a table', function() 26 | local validator = validateTable() 27 | 28 | expect(validator(nil)) 29 | .to.be(false) 30 | 31 | expect(validator(true)) 32 | .to.be(false) 33 | 34 | expect(validator(12)) 35 | .to.be(false) 36 | 37 | expect(validator(function() end)) 38 | .to.be(false) 39 | 40 | expect(validator('test')) 41 | .to.be(false) 42 | end) 43 | 44 | it('returns a second return value of type string that represents the reason validation failed', function() 45 | local validator = validateTable() 46 | local isValid, reason = validator(nil) 47 | 48 | expect(type(reason)) 49 | .to.be('string') 50 | end) 51 | end) 52 | end) -------------------------------------------------------------------------------- /tests/specs/reactor/propTypes/tableShape.lua: -------------------------------------------------------------------------------- 1 | local tableShape = require('reactor.propTypes.tableShape') 2 | 3 | local function trueValidator() 4 | return true 5 | end 6 | 7 | local function falseValidator() 8 | return false, 'this validation failed' 9 | end 10 | 11 | describe('tableShape', function() 12 | describe('error states', function() 13 | it('causes an error is the table shape description is not expressed as a table', function() 14 | expect(function() 15 | tableShape() 16 | end).to.fail() 17 | 18 | expect(function() 19 | tableShape('test') 20 | end).to.fail() 21 | 22 | expect(function() 23 | tableShape(12) 24 | end).to.fail() 25 | 26 | expect(function() 27 | tableShape(true) 28 | end).to.fail() 29 | 30 | expect(function() 31 | tableShape(function() end) 32 | end).to.fail() 33 | end) 34 | end) 35 | 36 | describe('behaviour', function() 37 | it('returns a function to use for validation when passed a valid description', function() 38 | expect(type(tableShape({}))) 39 | .to.be('function') 40 | end) 41 | 42 | it('returns false if a table is not specified to the validator', function() 43 | local validateEmptyTable = tableShape({}) 44 | 45 | expect(validateEmptyTable()) 46 | .to.be(false) 47 | 48 | expect(validateEmptyTable('test')) 49 | .to.be(false) 50 | 51 | expect(validateEmptyTable(12)) 52 | .to.be(false) 53 | 54 | expect(validateEmptyTable(false)) 55 | .to.be(false) 56 | 57 | expect(validateEmptyTable(function() end)) 58 | .to.be(false) 59 | end) 60 | 61 | it('returns false if a table has a different number of keys to the validation description', function() 62 | local validateTable = tableShape({ 63 | test = trueValidator 64 | }) 65 | 66 | expect(validateTable({})) 67 | .to.be(false) 68 | 69 | expect(validateTable({1, 2})) 70 | .to.be(false) 71 | end) 72 | 73 | it('returns false if the specified keys in the able are named differently to the description', function() 74 | local validateTable = tableShape({ 75 | test = trueValidator, 76 | anotherTest = trueValidator 77 | }) 78 | 79 | expect(validateTable({ 80 | test = 'hi', 81 | notAnotherTest = 2 82 | })).to.be(false) 83 | 84 | expect(validateTable({ 85 | notTest = 'hi', 86 | anotherTest = 2 87 | })).to.be(false) 88 | end) 89 | 90 | it('returns false if any of the properties do not pass validation', function() 91 | local validateTable = tableShape({ 92 | test = trueValidator, 93 | anotherTest = falseValidator 94 | }) 95 | 96 | expect(validateTable({ 97 | test = 'hi', 98 | anotherTest = 2 99 | })).to.be(false) 100 | end) 101 | 102 | it('returns true when all properties pass validation', function() 103 | local validateTable = tableShape({ 104 | test = trueValidator, 105 | anotherTest = trueValidator 106 | }) 107 | 108 | expect(validateTable({ 109 | test = 'hi', 110 | anotherTest = 2 111 | })).to.be(true) 112 | end) 113 | 114 | it('matches an empty table correctly', function() 115 | local validateEmptyTable = tableShape({}) 116 | 117 | expect(validateEmptyTable({})) 118 | .to.be(true) 119 | end) 120 | 121 | it('passes the property value to the property validator', function() 122 | local passedValue = nil 123 | local testValue = 'aloha' 124 | 125 | local validateTable = tableShape({ 126 | test = function(value) 127 | passedValue = value 128 | return testValue == passedValue 129 | end 130 | }) 131 | 132 | validateTable({ 133 | test = testValue 134 | }) 135 | 136 | expect(passedValue) 137 | .to.be(testValue) 138 | end) 139 | 140 | it('does not return a second return value when validation is successful', function() 141 | local validator = tableShape({ test = trueValidator }) 142 | local isValid, reason = validator({ test = 'test' }) 143 | 144 | expect(reason) 145 | .to.be(nil) 146 | end) 147 | 148 | 149 | it('returns a second return value of type string that represents the reason validation failed', function() 150 | local validator = tableShape({ test = falseValidator }) 151 | local isValid, reason = validator({ test = 'test' }) 152 | 153 | expect(type(reason)) 154 | .to.be('string') 155 | end) 156 | end) 157 | end) -------------------------------------------------------------------------------- /tests/specs/reactor/propTypes/value.lua: -------------------------------------------------------------------------------- 1 | local value = require('reactor.propTypes.value') 2 | 3 | describe('value', function() 4 | describe('error states', function() 5 | it('causes an error when passed nil as the argument', function() 6 | expect(function() 7 | value() 8 | end).to.fail() 9 | 10 | expect(function() 11 | value(nil) 12 | end).to.fail() 13 | end) 14 | end) 15 | 16 | describe('behaviour', function() 17 | it('returns a function to use for validation when passed a valid description', function() 18 | expect(type(value({trueValidator, falseValidator}))) 19 | .to.be('function') 20 | end) 21 | 22 | it('returns true when an exact value is matched', function() 23 | local testValidator = value('test') 24 | 25 | expect(testValidator('test')) 26 | .to.be(true) 27 | 28 | local twelveValidator = value(12) 29 | 30 | expect(twelveValidator(12)) 31 | .to.be(true) 32 | end) 33 | 34 | it('returns false when a value is not matched', function() 35 | local testValidator = value('test') 36 | 37 | expect(testValidator('notTest')) 38 | .to.be(false) 39 | 40 | expect(testValidator(4)) 41 | .to.be(false) 42 | 43 | expect(testValidator(nil)) 44 | .to.be(false) 45 | 46 | expect(testValidator(true)) 47 | .to.be(false) 48 | 49 | expect(testValidator(function() end)) 50 | .to.be(false) 51 | 52 | expect(testValidator({})) 53 | .to.be(false) 54 | 55 | local twelveValidator = value(12) 56 | 57 | expect(twelveValidator('12')) 58 | .to.be(false) 59 | 60 | expect(twelveValidator(5)) 61 | .to.be(false) 62 | 63 | expect(twelveValidator(nil)) 64 | .to.be(false) 65 | 66 | expect(twelveValidator(true)) 67 | .to.be(false) 68 | 69 | expect(twelveValidator(function() end)) 70 | .to.be(false) 71 | 72 | expect(twelveValidator({})) 73 | .to.be(false) 74 | end) 75 | 76 | it('does not return a second return value when validation is successful', function() 77 | local validator = value('test') 78 | local isValid, reason = validator('test') 79 | 80 | expect(reason) 81 | .to.be(nil) 82 | end) 83 | 84 | it('returns a second return value of type string that represents the reason validation failed', function() 85 | local validator = value('test') 86 | local isValid, reason = validator(true) 87 | 88 | expect(type(reason)) 89 | .to.be('string') 90 | end) 91 | end) 92 | end) -------------------------------------------------------------------------------- /tests/specs/reactor/reconcile.lua: -------------------------------------------------------------------------------- 1 | local reconcileModule = require('reactor.reconcile') 2 | local reconcile = reconcileModule() 3 | 4 | describe('#reconcile', function() 5 | describe('errors', function() 6 | it('throws an error when called without a table as the first argument', function() 7 | expect(function() reconcile(nil) end) 8 | .to.fail() 9 | 10 | expect(function() reconcile(1) end) 11 | .to.fail() 12 | 13 | expect(function() reconcile('a') end) 14 | .to.fail() 15 | 16 | expect(function() reconcile(false) end) 17 | .to.fail() 18 | end) 19 | 20 | it('throws an error when called without a table or nil as the second argument', function() 21 | expect(function() reconcile(1) end) 22 | .to.fail() 23 | 24 | expect(function() reconcile('a') end) 25 | .to.fail() 26 | 27 | expect(function() reconcile(false) end) 28 | .to.fail() 29 | end) 30 | end) 31 | 32 | describe('behaviour', function() 33 | it('returns an empty object describing the operations when called with an empty object', function() 34 | expect(reconcile({})) 35 | .to.equal({}) 36 | end) 37 | 38 | describe('shallow comparison', function() 39 | it('returns an object with a single `create` entry when given the appropriate description on its own', function() 40 | local description = { 41 | props = {}, 42 | name = "test", 43 | path = 'test' 44 | } 45 | 46 | local operations = reconcile(description) 47 | 48 | expect(#operations) 49 | .to.be(1) 50 | 51 | expect(operations[1].path) 52 | .to.be('test') 53 | 54 | expect(operations[1].operationType) 55 | .to.be('create') 56 | end) 57 | 58 | it('returns an object with a single `create` entry when given the appropriate description in a collection', function() 59 | local description = { 60 | { 61 | props = {}, 62 | name = "test", 63 | path = "1.test" 64 | } 65 | } 66 | 67 | local operations = reconcile(description) 68 | 69 | expect(#operations) 70 | .to.be(1) 71 | 72 | expect(operations[1].path) 73 | .to.be('1.test') 74 | 75 | expect(operations[1].operationType) 76 | .to.be('create') 77 | end) 78 | 79 | it('returns an object with a two `create` entries when given the appropriate description', function() 80 | local description = { 81 | { 82 | props = {}, 83 | name = "test_1", 84 | path = "1.test_1" 85 | }, 86 | { 87 | props = {}, 88 | name = "test_2", 89 | path = "2.test_2" 90 | } 91 | } 92 | 93 | local operations = reconcile(description) 94 | 95 | expect(#operations) 96 | .to.be(2) 97 | 98 | expect(operations[1].path) 99 | .to.be('1.test_1') 100 | expect(operations[1].operationType) 101 | .to.be('create') 102 | 103 | expect(operations[2].path) 104 | .to.be('2.test_2') 105 | expect(operations[2].operationType) 106 | .to.be('create') 107 | end) 108 | 109 | it('returns an object with a two `update` entries when given the appropriate description', function() 110 | local description = { 111 | { 112 | props = {}, 113 | name = "test_1", 114 | path = "1.test_1" 115 | }, 116 | { 117 | props = {}, 118 | name = "test_2", 119 | path = "2.test_2" 120 | } 121 | } 122 | 123 | local operations = reconcile(description, description) 124 | 125 | expect(#operations) 126 | .to.be(2) 127 | 128 | expect(operations[1].path) 129 | .to.be('1.test_1') 130 | expect(operations[1].operationType) 131 | .to.be('update') 132 | 133 | expect(operations[2].path) 134 | .to.be('2.test_2') 135 | expect(operations[2].operationType) 136 | .to.be('update') 137 | end) 138 | 139 | it('returns an object with a two `delete` entries when given the appropriate description', function() 140 | local description = { 141 | { 142 | props = {}, 143 | name = "test_1", 144 | path = "1.test_1" 145 | }, 146 | { 147 | props = {}, 148 | name = "test_2", 149 | path = "2.test_2" 150 | } 151 | } 152 | 153 | local operations = reconcile({}, description) 154 | 155 | expect(#operations) 156 | .to.be(2) 157 | 158 | expect(operations[1].path) 159 | .to.be('1.test_1') 160 | expect(operations[1].operationType) 161 | .to.be('delete') 162 | 163 | expect(operations[2].path) 164 | .to.be('2.test_2') 165 | expect(operations[2].operationType) 166 | .to.be('delete') 167 | end) 168 | end) 169 | 170 | describe('deep comparison', function() 171 | it('returns an object with a four `create` entries when given the appropriate descriptions', function() 172 | local description = { 173 | { 174 | props = {}, 175 | name = "test_1", 176 | path = "1.test_1" 177 | }, 178 | { 179 | { 180 | props = {}, 181 | name = "test_2", 182 | path = "2.1.test_2" 183 | } 184 | }, 185 | { 186 | { 187 | { 188 | props = {}, 189 | name = "test_3", 190 | path = "3.1.1.test_3" 191 | }, 192 | { 193 | props = {}, 194 | name = "test_4", 195 | path = "3.1.2.test_4" 196 | } 197 | } 198 | } 199 | } 200 | 201 | local operations = reconcile(description) 202 | 203 | expect(#operations) 204 | .to.be(4) 205 | 206 | expect(operations[1].path) 207 | .to.be('1.test_1') 208 | expect(operations[1].operationType) 209 | .to.be('create') 210 | 211 | expect(operations[2].path) 212 | .to.be('2.1.test_2') 213 | expect(operations[2].operationType) 214 | .to.be('create') 215 | 216 | expect(operations[3].path) 217 | .to.be('3.1.1.test_3') 218 | expect(operations[3].operationType) 219 | .to.be('create') 220 | 221 | expect(operations[4].path) 222 | .to.be('3.1.2.test_4') 223 | expect(operations[4].operationType) 224 | .to.be('create') 225 | end) 226 | 227 | it('returns an object with a two `create`, two `update` and two `delete` entries when given the appropriate descriptions', function() 228 | local currentDescription = { 229 | { 230 | props = {}, 231 | path = "1.test_1", 232 | name = "test_1" 233 | }, 234 | { 235 | { 236 | props = {}, 237 | path = "2.1.test_5", 238 | name = "test_5" 239 | } 240 | }, 241 | { 242 | { 243 | { 244 | props = {}, 245 | path = "3.1.1.test_3", 246 | name = "test_3" 247 | }, 248 | { 249 | props = {}, 250 | path = "3.1.2.test_6", 251 | name = "test_6" 252 | } 253 | } 254 | } 255 | } 256 | 257 | local lastDescription = { 258 | { 259 | props = {}, 260 | path = "1.test_1", 261 | name = "test_1" 262 | }, 263 | { 264 | { 265 | props = {}, 266 | path = "2.1.test_2", 267 | name = "test_2" 268 | } 269 | }, 270 | { 271 | { 272 | { 273 | props = {}, 274 | path = "3.1.1.test_3", 275 | name = "test_3" 276 | }, 277 | { 278 | props = {}, 279 | path = "3.1.2.test_4", 280 | name = "test_4" 281 | } 282 | } 283 | } 284 | } 285 | 286 | local operations = reconcile(currentDescription, lastDescription) 287 | 288 | expect(#operations) 289 | .to.be(6) 290 | 291 | expect(operations[1].path) 292 | .to.be('1.test_1') 293 | expect(operations[1].operationType) 294 | .to.be('update') 295 | 296 | expect(operations[2].path) 297 | .to.be('2.1.test_2') 298 | expect(operations[2].operationType) 299 | .to.be('delete') 300 | 301 | expect(operations[3].path) 302 | .to.be('2.1.test_5') 303 | expect(operations[3].operationType) 304 | .to.be('create') 305 | 306 | expect(operations[4].path) 307 | .to.be('3.1.1.test_3') 308 | expect(operations[4].operationType) 309 | .to.be('update') 310 | 311 | expect(operations[5].path) 312 | .to.be('3.1.2.test_4') 313 | expect(operations[5].operationType) 314 | .to.be('delete') 315 | 316 | expect(operations[6].path) 317 | .to.be('3.1.2.test_6') 318 | expect(operations[6].operationType) 319 | .to.be('create') 320 | end) 321 | 322 | it('returns an object with a one `create`, one `update` and one `delete` entries when given the appropriate descriptions', function() 323 | local currentDescription = { 324 | { 325 | props = {}, 326 | name = "test_1", 327 | path = "1.test_1" 328 | }, 329 | { 330 | { 331 | props = {}, 332 | name = "test_3", 333 | path = "2.1.test_3" 334 | } 335 | } 336 | } 337 | 338 | local lastDescription = { 339 | { 340 | props = {}, 341 | name = "test_1", 342 | path = "1.test_1" 343 | }, 344 | { 345 | props = {}, 346 | name = "test_2", 347 | path = "2.test_2" 348 | } 349 | } 350 | 351 | local operations = reconcile(currentDescription, lastDescription) 352 | 353 | expect(#operations) 354 | .to.be(3) 355 | 356 | expect(operations[1].path) 357 | .to.be('1.test_1') 358 | expect(operations[1].operationType) 359 | .to.be('update') 360 | 361 | expect(operations[2].path) 362 | .to.be('2.test_2') 363 | expect(operations[2].operationType) 364 | .to.be('delete') 365 | 366 | expect(operations[3].path) 367 | .to.be('2.1.test_3') 368 | expect(operations[3].operationType) 369 | .to.be('create') 370 | end) 371 | end) 372 | end) 373 | end) -------------------------------------------------------------------------------- /tests/suite.lua: -------------------------------------------------------------------------------- 1 | package.path = './src/?.lua;./src/?/init.lua;' .. package.path 2 | 3 | return { 4 | 'tests/specs/reactor/reconcile.lua', 5 | 'tests/specs/reactor/lifecycle.lua', 6 | 'tests/specs/reactor/component.lua', 7 | 'tests/specs/reactor/invoke.lua', 8 | 'tests/specs/reactor/propTypes/tableShape.lua', 9 | 'tests/specs/reactor/propTypes/optional.lua', 10 | 'tests/specs/reactor/propTypes/value.lua', 11 | 'tests/specs/reactor/propTypes/boolean.lua', 12 | 'tests/specs/reactor/propTypes/callable.lua', 13 | 'tests/specs/reactor/propTypes/function.lua', 14 | 'tests/specs/reactor/propTypes/number.lua', 15 | 'tests/specs/reactor/propTypes/string.lua', 16 | 'tests/specs/reactor/propTypes/table.lua', 17 | 'tests/specs/reactor/propTypes/oneOf.lua' 18 | } -------------------------------------------------------------------------------- /watch.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | ./test.sh 3 | fswatch -o ./ | xargs -n1 ./test.sh --------------------------------------------------------------------------------