This commit is contained in:
aaron 2023-07-30 14:35:46 +01:00
parent 1aa74310f3
commit 676bbbcc00
15 changed files with 665 additions and 873 deletions

View file

@ -1,41 +0,0 @@
------------------------------------------------------------------------------------------
-- vide/Event.lua
------------------------------------------------------------------------------------------
if not game then script = (require :: any) "test/wrap-require" end
local memoize = require(script.Parent.memoize)
local bind = require(script.Parent.bind)
local graph = require(script.Parent.graph)
type State<T> = graph.State<T>
type MaybeState<T> = graph.MaybeState<T>
local wrapped = graph.wrapped
local Types = require(script.Parent.Types)
type Listener = (unknown) -> ()
local getEventSymbol = memoize(function(name: string): Types.Symbol<MaybeState<Listener>>
return {
priority = 2,
run = function(instance: Instance, listener: MaybeState<Listener>)
local event: RBXScriptSignal<...unknown> = (instance :: any)[name]
if type(listener) == "function" then
event:Connect(listener)
elseif wrapped(listener) then
bind.event(listener :: State<Listener>, instance, event)
else
error("Attempt to connect non-function to event", 2)
end
end
}
end)
local Event = table.freeze(setmetatable({}, {__index = function(_, index: string)
return getEventSymbol(index)
end})) :: any
return Event :: { [string]: unknown }

View file

@ -4,12 +4,57 @@
if not game then script = (require :: any) "test/wrap-require" end if not game then script = (require :: any) "test/wrap-require" end
local applyProperties = require(script.Parent.applyProperties) local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
local function apply<T>(instance: T & Instance) local throw = require(script.Parent.throw)
return function(properties: { [any]: unknown }): T local bind = require(script.Parent.bind)
return applyProperties(instance, properties)
local function recurse(instance: Instance, properties: { [unknown]: unknown }, event_buffer)
for property, value in properties do
if type(value) == "table" then
recurse(instance, value :: {}, event_buffer)
elseif type(property) == "string" then
if type(value) == "function" then
if typeof((instance :: any)[property] == "RBXScriptSignal") then
event_buffer[property] = value
else
bind.property(instance, property, value :: () -> ())
end
else
(instance :: any)[property] = value
end
elseif type(property) == "number" then
if type(value) == "function" then
bind.children(instance, value :: () -> { Instance })
else
(value :: Instance).Parent = instance
end
end
end end
end end
local function apply<T>(instance: T & Instance, properties: { [unknown]: unknown }): T
local parent: unknown = properties.Parent
if parent then properties.Parent = nil end
local event_buffer: { [string]: () -> () } = {} -- connect events after setting properties
recurse(instance, properties, event_buffer)
for event, fn in next, event_buffer do
(instance :: any)[event]:Connect(fn)
end
if parent then
if type(parent) == "function" then
error("cannot set parent to state")
else
instance.Parent = parent :: Instance
end
end
return instance
end
return apply return apply

View file

@ -1,66 +0,0 @@
------------------------------------------------------------------------------------------
-- vide/applyProperties.lua
------------------------------------------------------------------------------------------
if not game then script = (require :: any) "test/wrap-require" end
local graph = require(script.Parent.graph)
type State<T> = graph.State<T>
local wrapped = graph.wrapped
local throw = require(script.Parent.throw)
local bind = require(script.Parent.bind)
local Types = require(script.Parent.Types)
local function applyProperty(instance: Instance, property: string, value: unknown)
end
local function applyProperties<T>(instance: T & Instance, properties: { [string|Types.Symbol<unknown> ]: unknown }): T
local parent: unknown = properties.Parent
if parent then properties.Parent = nil end
local eventBuffer: { [(Instance, () -> ()) -> ()]: () -> () } = {} -- connect events after setting properties
local postCreation: (Instance) -> ()? = nil -- buffer for post-creation callback
for property, value in next, properties do
if type(property) == "string" then
if wrapped(value) then
bind.property(value :: State<unknown>, instance, property)
else
(instance :: any)[property] = value
end
elseif type(property) == "table" then
local priority = property.priority
if priority == 1 then
property.run(instance, value)
elseif priority == 2 then
eventBuffer[property.run] = value :: () -> ()
elseif priority == 3 then
assert(not postCreation)
postCreation = value :: () -> ()
else
error("invalid priority")
end
else throw(`Invalid property { tostring(property) }, expected string or symbol`) end
end
for fn, v in next, eventBuffer do
fn(instance, v)
end
if parent then
applyProperty(instance, "Parent", parent)
if wrapped(parent) then
bind.parent(parent :: State<Instance?>, instance)
else
instance.Parent = parent :: Instance
end
end
if postCreation then postCreation(instance) end
return instance
end
return applyProperties

View file

@ -11,9 +11,10 @@ end
local graph = require(script.Parent.graph) local graph = require(script.Parent.graph)
type State<T> = graph.State<T> type Node<T> = graph.Node<T>
local get = graph.get local get = graph.get
local setEffect = graph.setEffect local set_effect = graph.set_effect
local capture = graph.capture
local throw = require(script.Parent.throw) local throw = require(script.Parent.throw)
local flags = require(script.Parent.flags) local flags = require(script.Parent.flags)
@ -22,15 +23,18 @@ local hold: { Instance? } = {}
local weak: { Instance? } = setmetatable({}, { __mode = "v" }) :: any local weak: { Instance? } = setmetatable({}, { __mode = "v" }) :: any
local bindcount = 0 local bindcount = 0
local srcs do local srcs do
local src1 = debug.info(1, "s") local src1 = debug.info(1, "s")
local srctrunc = string.sub(src1, 1, #src1-4) local srctrunc = string.sub(src1, 1, #src1-4)
srcs = { srcs = {
src1, src1,
srctrunc .. "applyProperties", srctrunc .. "apply",
srctrunc .. "create", srctrunc .. "create",
srctrunc .. "apply"
} }
end end
@ -43,18 +47,22 @@ local function traceback() -- ensures trace begins outside of any vide library f
return debug.traceback("", s) return debug.traceback("", s)
end end
function setup(state: State<any>, instance: Instance, updateInstance: (Instance) -> ()) function setup(instance: Instance, deriver: () -> unknown, setter: (Instance) -> ())
if flags.strict then if flags.strict then
local fn = updateInstance local fn = setter
local trace = traceback() local trace = traceback()
updateInstance = function(instance) setter = function(instance)
local ok, err: string? = pcall(fn, instance) local ok, err: string? = pcall(fn, instance)
if not ok then warn(`error occured updating state binding:\n{err}\nset from:{trace}`) end if not ok then warn(`error occured updating state binding:\n{err}\nset from:{trace}`) end
end end
end end
updateInstance(instance) local nodes = table.clone((capture(setter :: (Instance) -> unknown, instance)))
setEffect(state, updateInstance, instance)
for _, node in next, nodes do
set_effect(node, setter, instance)
end
bindcount += 1 bindcount += 1
local key = bindcount local key = bindcount
@ -62,7 +70,7 @@ function setup(state: State<any>, instance: Instance, updateInstance: (Instance)
weak[key] = instance weak[key] = instance
local function ref() local function ref()
local _ = state -- prevent gc of state while instance exists local _ = nodes -- prevent gc of state while instance exists
local instance = weak[key] :: Instance local instance = weak[key] :: Instance
hold[key] = instance.Parent and instance or nil -- prevent gc of instance while parented hold[key] = instance.Parent and instance or nil -- prevent gc of instance while parented
end end
@ -71,28 +79,28 @@ function setup(state: State<any>, instance: Instance, updateInstance: (Instance)
instance:GetPropertyChangedSignal("Parent"):Connect(ref) instance:GetPropertyChangedSignal("Parent"):Connect(ref)
end end
local function bindProperty(state: State<unknown>, instance_STRONG: Instance, property: string) local function bind_property(instance: Instance, property: string, fn: () -> unknown)
setup(state, instance_STRONG, function(instance) setup(instance, fn, function(instance_weak: any)
(instance :: any)[property] = get(state) instance_weak[property] = fn()
end) end)
end end
local function bindParent(state: State<Instance?>, instance_STRONG) local function bind_parent(instance: Instance, fn: () -> Instance?)
instance_STRONG.Destroying:Connect(function() instance.Destroying:Connect(function()
instance_STRONG = nil :: any -- allow gc when destroyed instance= nil :: any -- allow gc when destroyed
end) end)
setup(state, instance_STRONG, function(instance) setup(instance, fn, function(instance)
local _ = instance_STRONG -- state will strongly reference instance when parent is bound local _ = instance -- state will strongly reference instance when parent is bound
instance.Parent = get(state) instance.Parent = fn()
end) end)
end end
local function bindChildren(state: State<{ Instance }?>, parent_STRONG: Instance) local function bind_children(parent: Instance, fn: () -> { Instance })
local currentChildrenSet: { [Instance]: true } = {} -- cache of all children parented before update local currentChildrenSet: { [Instance]: true } = {} -- cache of all children parented before update
local newChildrenSet: { [Instance]: true } = {} -- cache of all children parented after update local newChildrenSet: { [Instance]: true } = {} -- cache of all children parented after update
setup(state, parent_STRONG, function(parent) setup(parent, fn, function(parent_weak)
local newChildren = get(state) -- all (and only) children that should be parented after this update local newChildren = fn() -- all (and only) children that should be parented after this update
if newChildren and type(newChildren) ~= "table" then if newChildren and type(newChildren) ~= "table" then
throw(`Cannot parent instance of type { type(newChildren) } `) throw(`Cannot parent instance of type { type(newChildren) } `)
end end
@ -101,7 +109,7 @@ local function bindChildren(state: State<{ Instance }?>, parent_STRONG: Instance
for _, child in next, newChildren do for _, child in next, newChildren do
newChildrenSet[child] = true -- record child set from this update newChildrenSet[child] = true -- record child set from this update
if not currentChildrenSet[child] then if not currentChildrenSet[child] then
child.Parent = parent -- if child wasn't already parented then parent it child.Parent = parent_weak -- if child wasn't already parented then parent it
else else
currentChildrenSet[child] = nil -- remove child from cache if it was already in cache currentChildrenSet[child] = nil -- remove child from cache if it was already in cache
end end
@ -117,23 +125,8 @@ local function bindChildren(state: State<{ Instance }?>, parent_STRONG: Instance
end) end)
end end
local function bindEvent(state: State<() -> ()?>, instance_STRONG: Instance, event: RBXScriptSignal)
local current: RBXScriptConnection? = nil
setup(state, instance_STRONG, function(instance)
if current then
current:Disconnect()
current = nil
end
local listener = get(state)
if listener then
current = event:Connect(listener)
end
end)
end
return { return {
property = bindProperty, property = bind_property,
parent = bindParent, parent = bind_parent,
children = bindChildren, children = bind_children,
event = bindEvent
} }

View file

@ -9,7 +9,7 @@ end
local throw = require(script.Parent.throw) local throw = require(script.Parent.throw)
local defaults = require(script.Parent.defaults) local defaults = require(script.Parent.defaults)
local applyProperties = require(script.Parent.applyProperties) local apply = require(script.Parent.apply)
local memoize = require(script.Parent.memoize) local memoize = require(script.Parent.memoize)
local function createInstance(className: string) local function createInstance(className: string)
@ -24,7 +24,7 @@ local function createInstance(className: string)
end end
return function(properties: { [any]: unknown }): Instance return function(properties: { [any]: unknown }): Instance
return applyProperties(instance:Clone(), properties) return apply(instance:Clone(), properties)
end end
end; createInstance = memoize(createInstance) end; createInstance = memoize(createInstance)
@ -32,7 +32,7 @@ local function cloneInstance(instance: Instance)
return function(properties: { [any]: unknown }): Instance return function(properties: { [any]: unknown }): Instance
local clone = instance:Clone() local clone = instance:Clone()
if not clone then error("Attempt to clone a non-archivable instance", 3) end if not clone then error("Attempt to clone a non-archivable instance", 3) end
return applyProperties(clone, properties) return apply(clone, properties)
end end
end end

View file

@ -5,28 +5,28 @@
if not game then script = (require :: any) "test/wrap-require" end if not game then script = (require :: any) "test/wrap-require" end
local graph = require(script.Parent.graph) local graph = require(script.Parent.graph)
type State<T> = graph.State<T>
type Unwrapper = graph.Unwrapper
local create = graph.create local create = graph.create
local captureAndLink = graph.captureAndLink local get = graph.get
local capture_and_link = graph.capture_and_link
local function derive<T>(deriveValue: (Unwrapper) -> T, cleanup: (T) -> ()?): State<T> local function derive<T>(fn: () -> T, cleanup: (T) -> ()?): () -> T
local node = create((nil :: any) :: T) local node = create((nil :: any) :: T)
if cleanup then if cleanup then
local fn = deriveValue local f = fn
local last: T? = nil local last: T? = nil
deriveValue = function(from) fn = function()
if last ~= nil then cleanup(last) end if last ~= nil then cleanup(last) end
last = fn(from) last = f()
return last :: T return last :: T
end end
end end
local value: T = captureAndLink(node, deriveValue) node.cache = capture_and_link(node, fn)
rawset(node, "__cache", value)
return node :: State<T> return function()
return get(node)
end
end end
return derive return derive

View file

@ -6,49 +6,29 @@ if not game then script = (require :: any) "test/wrap-require" end
local flags = require(script.Parent.flags) local flags = require(script.Parent.flags)
export type State<T> = typeof(setmetatable( export type Node<T> = {
{} :: { cache: T,
__cache: T, derive: () -> T,
__updated: boolean, effects: { [(unknown) -> ()]: unknown }, -- weak values
__derive: (any) -> T, children: { Node<T> } | false -- weak values
__effects: { [(unknown) -> ()]: unknown } | false, -- weak values }
__children: { State<T> } | false -- weak values
}, {} :: {
__concat: (any, any) -> any,
__add: (any, any) -> any,
__sub: (any, any) -> any,
__mul: (any, any) -> any,
__div: (any, any) -> any,
--__pow
--__mod
--__unm
--__eq: (unknown, unknown) -> State<boolean>
--__lt
--__le
}
))
export type MaybeState<T> = State<T> | T local reff = false
export type Unwrapper = <T>(T) -> T local refs = {} :: { Node<unknown> }
local WEAK_VALUES_RESIZABLE = { __mode = "vs" } local WEAK_VALUES_RESIZABLE = { __mode = "vs" }
local EVALUATION_ERR = "error while evaluating state:\n\n" local EVALUATION_ERR = "error while evaluating node:\n\n"
local State = {} setmetatable(refs, WEAK_VALUES_RESIZABLE)
local function wrapped(value: any): boolean local check_for_yield do
return getmetatable(value) == State
end
local unwrap: <T>(T) -> T;
local checkForYield do
local t = { __mode = "kv" } local t = { __mode = "kv" }
setmetatable(t, t) setmetatable(t, t)
checkForYield = function(fn: (Unwrapper) -> ()) check_for_yield = function<T..., U...>(fn: (T...) -> (), ...: U...)
local args = { ... }
t.__unm = function() t.__unm = function()
fn(unwrap) fn(unpack(args))
end end
local ok, err = pcall(function() local ok, err = pcall(function()
return -t :: any return -t :: any
@ -56,7 +36,7 @@ local checkForYield do
if not ok then if not ok then
if err == "attempt to yield across metamethod/C-call boundary" or err == "thread is not yieldable" then if err == "attempt to yield across metamethod/C-call boundary" or err == "thread is not yieldable" then
error(EVALUATION_ERR .. "cannot yield when deriving state in watcher", 3) error(EVALUATION_ERR .. "cannot yield when deriving node in watcher", 3)
else else
error(EVALUATION_ERR..err, 3) error(EVALUATION_ERR..err, 3)
end end
@ -64,199 +44,100 @@ local checkForYield do
end end
end end
local function setEffect<T>(state: State<unknown>, fn: (T) -> (), key: T) local function set_effect<T>(node: Node<unknown>, fn: (T) -> (), key: T)
if not state.__effects then node.effects[fn :: () -> ()] = key
state.__effects = setmetatable({ [fn] = key }, WEAK_VALUES_RESIZABLE) :: any
else
state.__effects[fn :: () -> ()] = key
end
end end
local function runEffects(state: State<unknown>) local function run_effects(node: Node<unknown>)
if state.__effects then for effect, key in next, node.effects do
for effect, key in next, state.__effects do if flags.strict then effect(key) end
if flags.strict then effect(key) end effect(key)
effect(key) end
end
end
end end
-- retrieves a state's cached value -- retrieves a node's cached value
-- recalculates value if an ancestor was updated -- recalculates value if an ancestor was updated
local function get<T>(state: State<T>): T local function get<T>(node: Node<T>): T
if state.__updated then if reff then table.insert(refs, node) end
state.__updated = false return node.cache
if flags.strict then checkForYield(state.__derive) end
local ok, result: T|string? = pcall(state.__derive, unwrap); if ok then
rawset(state :: any, "__cache", result :: T)
else error(EVALUATION_ERR .. result :: string, 0) end
end
return rawget(state :: any, "__cache")
end end
-- utility function for retrieving a value from state and allowing passthrough of non-state local function set_child(parent: Node<unknown>, child: Node<unknown>)
unwrap = function<T>(value: MaybeState<T>): T if parent.children then
if wrapped(value) then table.insert(parent.children, child)
return get(value :: State<T>)
else else
return value :: T parent.children = { child }
setmetatable(parent.children :: any, WEAK_VALUES_RESIZABLE)
end end
end end
local function addChild(parent: State<unknown>, child: State<unknown>) -- runs node effects, recalculates descendants and runs descendant effects
if parent.__children then local function update(node: Node<unknown>)
table.insert(parent.__children, child) run_effects(node)
else if node.children then
parent.__children = setmetatable({ child }, WEAK_VALUES_RESIZABLE) :: any for _, child in node.children do
end if flags.strict then check_for_yield(child.derive) end
end child.cache = child.derive()
update(child)
-- marks all state descendants for recalculation and runs effects
local function update(state: State<unknown>)
runEffects(state)
if state.__children then
for _, child in state.__children do
if not child.__updated then
child.__updated = true
update(child)
end
end end
end end
end end
-- sets a state's cached value and updates all descendants -- sets a node's cached value and updates all descendants
local function set<T>(state: State<T>, value: T) local function set<T>(node: Node<T>, value: T)
state.__cache = value node.cache = value
update(state) update(node)
end end
-- links two states as parent-child -- links two nodes as parent-child with a function to compute a new value for child
local function link(parent: State<unknown>, child: State<unknown>, derive: () -> unknown) local function link<T>(parent: Node<unknown>, child: Node<T>, derive: () -> T)
child.__derive = derive child.derive = derive
addChild(parent, child) set_child(parent, child)
end end
-- detect what states were referenced in the given callback and returns them in an array -- detect what nodes were referenced in the given callback and returns them in an array
local function capture<T>(callback: (Unwrapper) -> T): ({ State<unknown> }, T) local function capture<T, U>(fn: (U) -> T, arg: U): ({ Node<unknown> }, T)
if flags.strict then checkForYield(callback) end if flags.strict then check_for_yield(fn, arg) end
local states = table.create(2) table.clear(refs)
reff = true
local ok: boolean, result: T|string = pcall(callback, function<T>(value: MaybeState<T>): T local ok: boolean, result: T|string = pcall(fn, arg)
if wrapped(value) then
table.insert(states, value :: State<T>) reff = false
return get(value :: State<T>)
else
return value :: T
end
end)
if not ok then error("error while detecting watcher: " .. result :: string, 0) end if not ok then error("error while detecting watcher: " .. result :: string, 0) end
return states, result :: T return refs, result :: T
end end
-- captures and links any detected states -- captures and links any detected nodes
local function captureAndLink<T>(child: State<T>, callback: (Unwrapper) -> T): T local function capture_and_link<T>(child: Node<T>, fn: () -> T): T
local states, value = capture(callback) local nodes, value = capture(fn, nil)
child.__derive = callback child.derive = fn
for _, parent: State<unknown> in next, states do for _, parent: Node<unknown> in next, nodes do
addChild(parent, child) set_child(parent, child)
end end
return value :: T return value :: T
end end
local create: <T>(value: T) -> State<T> local function create<T>(value: T): Node<T>
return {
-- factory function for creating operator overloads for shorthands to derive state cache = value,
local function overload(op: (unknown, unknown) -> unknown): (any, any) -> any derive = function() return nil :: any end,
return function(a: MaybeState<unknown>, b: MaybeState<unknown>): State<unknown> effects = setmetatable({}, WEAK_VALUES_RESIZABLE) :: any,
local derived: State<unknown> = create(nil :: any) children = false :: false
}
local aIsState = wrapped(a)
local bIsState = wrapped(b)
if aIsState and bIsState then
local function derive() return op(get(a :: State<unknown>), get(b :: State<unknown>)) end
link(a :: State<unknown>, derived, derive)
link(b :: State<unknown>, derived, derive)
elseif aIsState then
link(a :: State<unknown>, derived, function() return op(get(a :: State<unknown>), b) end)
else--if bIsState then
link(b :: State<unknown>, derived, function() return op(a, get(b :: State<unknown>)) end)
end
derived.__updated = true
return derived
end
end
local function __unm(self: State<unknown>)
local derived = create(nil :: any)
link(self, derived, function()
return -get(self) :: number
end)
derived.__updated = true
return derived
end
local function __index(self: State<unknown>, index: unknown)
local derived = create(nil :: any)
link(self, derived, function()
return (get(self) :: {})[index]
end)
derived.__updated = true
return derived
end
State.__index = __index
State.__concat = overload(function(a: any, b: any) return tostring(a) .. tostring(b) end)
State.__add = overload(function(a: any, b: any) return a + b end)
State.__sub = overload(function(a: any, b: any) return a - b end)
State.__mul = overload(function(a: any, b: any) return a * b end)
State.__div = overload(function(a: any, b: any) return a / b end)
State.__pow = overload(function(a: any, b: any) return a ^ b end)
State.__mod = overload(function(a: any, b: any) return a % b end)
State.__unm = __unm
-- todo: what to do
do
local function err()
error("cannot perform equality comparison with state", 2)
end
State.__eq = err
State.__lt = err
State.__le = err
end
function create<T>(value: T): State<T>
return setmetatable({
__cache = value,
__updated = false,
__derive = function() return nil end :: any,
__effects = false :: false,
__children = false :: false
}, State)
end end
return table.freeze { return table.freeze {
setEffect = setEffect, set_effect = set_effect,
get = get, get = get,
set = set, set = set,
unwrap = unwrap,
link = link, link = link,
capture = capture, capture = capture,
captureAndLink = captureAndLink, capture_and_link = capture_and_link,
wrapped = wrapped,
create = create, create = create,
} }

View file

@ -6,20 +6,13 @@
if not game then script = (require :: any) "test/wrap-require" end if not game then script = (require :: any) "test/wrap-require" end
local create = require(script.create) local create = require(script.create)
local apply = require(script.apply)
local wrap = require(script.wrap) local wrap = require(script.wrap)
local watch = require(script.watch) local watch = require(script.watch)
local derive = require(script.derive) local derive = require(script.derive)
local map = require(script.map) local map = require(script.map)
local unwrap = require(script.unwrap) -- local Changed = require(script.Change)
local wrapped = require(script.wrapped) -- local Created = require(script.Created)
local Layout = require(script.Layout)
local Children = require(script.Children)
local Event = require(script.Event)
local Changed = require(script.Change)
local Created = require(script.Created)
local spring, updateSprings = require(script.spring)() local spring, updateSprings = require(script.spring)()
@ -32,25 +25,20 @@ type Setter<T> = ( (new: T, force: true?) -> T ) & ( (update: (old: T) -> T, for
local vide = { local vide = {
-- core -- core
create = create, create = create,
apply = apply, wrap = wrap,
wrap = (wrap :: any) :: <T>(value: T?) -> (T, Setter<T>), derive = derive,
derive = (derive :: any) :: <T>(deriver: (from: <U>(U) -> U) -> T, cleanup: (T) -> ()?) -> T, map = map,
map = (map :: any) :: ( <V>(input: number, transform: (number) -> V, cleanup: (V) -> ()?) -> Map<number, V> ) & ( <K, VI, VO>(input: Map<K, VI>, transform: (K, VI) -> VO, cleanup: (VO) -> ()?) -> Map<K, VO> ), watch = watch,
watch = watch :: ((Unwrapper) -> ()) -> () -> (),
-- util
unwrap = unwrap,
wrapped = wrapped,
-- animations -- animations
spring = (spring :: any) :: <T>(input: T, period: number, damping: number?) -> T, spring = spring,
-- symbols -- symbols
Event = Event, -- Event = Event,
Changed = Changed, -- Changed = Changed,
Layout = Layout, -- Layout = Layout,
Children = Children, -- Children = Children,
Created = Created, -- Created = Created,
-- flags -- flags
strict = (nil :: any) :: boolean, strict = (nil :: any) :: boolean,

View file

@ -25,13 +25,9 @@ if not game then script = (require :: any) "test/wrap-require" end
local throw = require(script.Parent.throw) local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph) local graph = require(script.Parent.graph)
type State<T> = graph.State<T>
type MaybeState<T> = graph.MaybeState<T>
local create = graph.create local create = graph.create
local get = graph.get local get = graph.get
local set = graph.set local set = graph.set
local unwrap = graph.unwrap
local wrapped = graph.wrapped
type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3 type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3

View file

@ -1,9 +0,0 @@
--------------------------------------------------------------------------------------------------------------
-- vide/unwrap.lua
--------------------------------------------------------------------------------------------------------------
if not game then script = (require :: any) "test/wrap-require" end
local graph = require(script.Parent.graph)
return graph.unwrap

View file

@ -5,27 +5,27 @@
if not game then script = (require :: any) "test/wrap-require" end if not game then script = (require :: any) "test/wrap-require" end
local graph = require(script.Parent.graph) local graph = require(script.Parent.graph)
type State<T> = graph.State<T> local set_effect = graph.set_effect
type Unwrapper = graph.Unwrapper
local setEffect = graph.setEffect
local capture = graph.capture local capture = graph.capture
local unwrap = graph.unwrap
local function watch(effect: (Unwrapper) -> ()): () -> () local function watch(effect: () -> ()): () -> ()
local states, cleanup = capture(effect :: () -> (() -> ()?)) -- todo: call cleanup on initial debug call when strict
local nodes, cleanup = capture(effect :: () -> (() -> ()?))
nodes = table.clone(nodes)
local function fn() local function fn()
if cleanup then cleanup(); cleanup = nil end if cleanup then cleanup(); cleanup = nil end
cleanup = effect(unwrap) cleanup = effect()
end end
for _, state in next, states do for _, node in next, nodes do
setEffect(state, fn, true) set_effect(node, fn, true)
end end
local function unwatch() local function unwatch()
for _, state in next, states do for _, node in next, nodes do
setEffect(state, fn, nil) set_effect(node, fn, nil)
end end
if cleanup then cleanup(); cleanup = nil end if cleanup then cleanup(); cleanup = nil end
end end

View file

@ -2,41 +2,31 @@
-- vide/wrap.lua -- vide/wrap.lua
------------------------------------------------------------------------------------------ ------------------------------------------------------------------------------------------
if not game then script = (require :: any) "test/wrap-require" end if not game then script = require "test/wrap-require" end
local graph = require(script.Parent.graph) local graph = require(script.Parent.graph)
type State<T> = graph.State<T> type Node<T> = graph.Node<T>
type MaybeState<T> = graph.MaybeState<T>
local create = graph.create local create = graph.create
local get = graph.get local get = graph.get
local set = graph.set local set = graph.set
local wrapped = graph.wrapped
local throw = require(script.Parent.throw)
local flags = require(script.Parent.flags)
type Setter<T> = (value: MaybeState<T> | (MaybeState<T>) -> MaybeState<T>, force: boolean?) -> T type State<T> = (() -> T) & ((T) -> T)
local function wrap<T>(value: MaybeState<T>?): (State<T>, Setter<T>) local function wrap<T>(value: T | () -> T): State<T>
local state = create(if wrapped(value) then get(value :: State<T>) else value :: T) if type(value) == "function" then value = value() end
local function setter(vi: MaybeState<T> | (MaybeState<T>) -> MaybeState<T>, force: boolean?): T local node = create(value :: T)
if type(vi) == "function" then
vi = vi(get(state))
end
local v = if wrapped(vi) then get(vi :: State<T>) else vi :: T return function(...): T
if select("#", ...) == 0 then return get(node) end
if v ~= state.__cache or force then local v = ... :: T
set(state, v) if node.cache == v and type(v) ~= "table" then return v end
elseif flags.strict and type(v) == "table" then
throw("attempt to set same table object")
end
set(node, v)
return v return v
end end
return state, setter
end end
return wrap return wrap

View file

@ -1,9 +0,0 @@
------------------------------------------------------------------------------------------
-- vide/wrapped.lua
------------------------------------------------------------------------------------------
if not game then script = (require :: any) "test/wrap-require" end
local graph = require(script.Parent.graph)
return graph.wrapped

View file

@ -1,43 +1,101 @@
------------------------------------------------------------------------------------------ --------------------------------------------------------------------------------
-- testkit.luau -- testkit.luau
-- v0.2.0 -- v0.7.0
------------------------------------------------------------------------------------------ --------------------------------------------------------------------------------
--[[ local color = {
white_underline = function(s: string)
return `\27[1;4m{s}\27[0m`
end,
EXAMPLE USAGE: white = function(s: string)
return `\27[37;1m{s}\27[0m`
end,
local testkit = require "path-to-testkit" green = function(s: string)
return `\27[32;1m{s}\27[0m`
end,
local TEST, CASE, CHECK = testkit.getUnitTestTools() red = function(s: string)
return `\27[31;1m{s}\27[0m`
end,
TEST("test name", function() yellow = function(s: string)
do CASE "A" return `\27[33;1m{s}\27[0m`
CHECK(condition) end,
red_highlight = function(s: string)
return `\27[41;1;30m{s}\27[0m`
end,
green_highlight = function(s: string)
return `\27[42;1;30m{s}\27[0m`
end,
gray = function(s: string)
return `\27[30;1m{s}\27[0m`
end,
}
local function convert_units(unit: string, value: number): (number, string)
local prefix_colors = {
[3] = color.red,
[2] = color.yellow,
[1] = color.yellow,
[0] = color.green,
[-1] = color.red,
[-2] = color.yellow,
[-3] = color.green
}
local prefixes = {
[3] ="G",
[2] ="M",
[1] = "k",
[0] = " ",
[-1] = "m",
[-2] = "u",
[-3] = "n"
}
local order = 0
while value >= 1000 do
order += 1
value /= 1000
end end
end)
local BENCH, START = testkit.getBenchmarkTools() while value ~= 0 and value < 1 do
order -= 1
BENCH("benchmark name", function() value *= 1000
local x = 0
for i = 1, START(1e6) do
x += 1
end end
end)
]]
------------------------------------------------------------------------------------------ if value >= 100 then
-- Unit Testing value = math.floor(value)
------------------------------------------------------------------------------------------ elseif value >= 10 then
value = math.floor(value * 1e1) / 1e1
elseif value >= 1 then
value = math.floor(value * 1e2) / 1e2
end
return value, prefix_colors[order](prefixes[order] .. unit)
end
local WALL = color.gray ""
--------------------------------------------------------------------------------
-- Testing
--------------------------------------------------------------------------------
type Test = { type Test = {
name: string, name: string,
activeCase: Case?, case: Case?,
cases: { Case }, cases: { Case },
duration: number, duration: number,
error: string? error: {
message: string,
trace: string
}?
} }
type Case = { type Case = {
@ -46,185 +104,220 @@ type Case = {
line: number? line: number?
} }
local PASS = 1 local PASS, FAIL, NONE, ERROR = 1, 2, 3, 4
local FAIL = 2
local NONE = 3
local ERROR = 4
local activeTest: Test? local skip: string?
local test: Test?
local tests: { Test } = {} local tests: { Test } = {}
local function outputTestResults(test: Test) local function output_test_result(test: Test)
print("\27[1;4m"..test.name.."\27[0m") print(color.white(test.name))
for _, case in test.cases do for _, case in test.cases do
print( local status = ({
"[" .. [PASS] = color.green "PASS",
(if case.result == PASS then [FAIL] = color.red "FAIL",
"\27[32;1mPASS\27[0m" [NONE] = color.yellow "NONE",
elseif case.result == FAIL then [ERROR] = color.red "FAIL"
"\27[31;1mFAIL:"..assert(case.line).."\27[0m" })[case.result]
elseif case.result == NONE then
"\27[33;1mNONE\27[0m" local line = case.result == FAIL and color.red(`{case.line}:`) or ""
else
"\27[41;1;30mERROR\27[0m") print(`{status}{WALL} {line}{color.gray(case.name)}`)
.. "] " .. case.name
)
end end
if test.error then if test.error then
print("\27[31;1;30merror: " .. test.error .. "\27[0m") print(color.gray "error: " .. color.red(test.error.message))
print(color.gray "trace: " .. color.red(test.error.trace))
else
print()
end end
print ""
end end
local function CASE(name: string) local function CASE(name: string)
assert(activeTest, "no active test") assert(test, "no active test")
local case: Case = { local case = {
name = name, name = name,
result = NONE result = NONE
} }
activeTest.activeCase = case test.case = case
table.insert(activeTest.cases, case) table.insert(test.cases, case)
end end
local function CHECK(value: any): boolean local function CHECK<T>(value: T, stack: number?): T
assert(activeTest, "no active test") assert(test, "no active test")
local activeCase = activeTest.activeCase local case = test.case
if not activeCase then if not case then
CASE "" CASE ""
activeCase = activeTest.activeCase case = test.case
end; assert(activeCase, "no active case")
local result = value and PASS or FAIL
if activeCase.result == NONE or activeCase.result == PASS then
activeCase.result = result
activeCase.line = debug.info(2, "l")
end end
return result == PASS assert(case, "no active case")
if case.result ~= FAIL then
case.result = value and PASS or FAIL
case.line = debug.info(stack and stack + 1 or 2, "l")
end
return value
end end
local function TEST(name: string, fn: () -> ()) local function TEST(name: string, fn: () -> ())
assert(not activeTest, "new test was started while a test was in progress") if skip and name ~= skip then return end
local test: Test = {
local active = test
assert(not active, "cannot start test while another test is in progress")
test = {
name = name, name = name,
cases = {}, cases = {},
duration = 0 duration = 0
} }; assert(test)
activeTest = test
table.insert(tests, test) table.insert(tests, test)
local start = os.clock() local start = os.clock()
local msg: string? local err
local success = xpcall(fn, function(m: string) msg = m .. debug.traceback("", 2) end) local success = xpcall(fn, function(m: string)
err = { message = m, trace = debug.traceback(nil, 2) }
end)
test.duration = os.clock() - start test.duration = os.clock() - start
if not test.activeCase then CASE "" end if not test.case then CASE "" end
assert(test.activeCase, "no active case") assert(test.case, "no active case")
if not success then if not success then
test.activeCase.result = ERROR test.case.result = ERROR
test.error = msg test.error = err
end end
activeTest = nil test = nil
outputTestResults(test)
end end
local function FINISH(): boolean local function FINISH(): boolean
local success = true local success = true
local totalCases = 0 local total_cases = 0
local passedCases = 0 local passed_cases = 0
local duration = 0 local duration = 0
for _, test in tests do for _, test in tests do
duration += test.duration duration += test.duration
for _, case in test.cases do for _, case in test.cases do
totalCases += 1 total_cases += 1
if case.result == PASS or case.result == NONE then if case.result == PASS or case.result == NONE then
passedCases += 1 passed_cases += 1
else else
success = false success = false
end end
end end
output_test_result(test)
end end
print(string.format("%d/%d test cases passed in %.3f ms.", passedCases, totalCases, duration*1e3)) print(color.gray(string.format(
`{passed_cases}/{total_cases} test cases passed in %.3f ms.`,
duration*1e3
)))
local fails = totalCases - passedCases local fails = total_cases - passed_cases
print(string.format("\27[%d;1;30m%d fail%s\27[0m", fails > 0 and 41 or 42, fails, fails == 1 and "" or "s")) print(
(
fails > 0
and color.red
or color.green
)(`{fails} {fails == 1 and "fail" or "fails"}`)
)
return success, table.clear(tests) return success, table.clear(tests)
end end
------------------------------------------------------------------------------------------ local function SKIP(name: string)
assert(not test, "cannot skip during test")
skip = name
end
--------------------------------------------------------------------------------
-- Benchmarking -- Benchmarking
------------------------------------------------------------------------------------------ --------------------------------------------------------------------------------
type Bench = { type Bench = {
timeStart: number?, time_start: number?,
memStart: number?, memory_start: number?,
iterations: number? iterations: number?
} }
local activeBench: Bench? = nil local bench: Bench?
function START(iter: number?): number function START(iter: number?): number
local n = iter or 1 local n = iter or 1
if n < 1 then error("iteration count must be greater than 0", 2) end assert(n > 0, "iterations must be greater than 0")
assert(activeBench, "no active benchmark") assert(bench, "no active benchmark")
assert(not activeBench.timeStart, "clock was already started") assert(not bench.time_start, "clock was already started")
activeBench.iterations = n bench.iterations = n
activeBench.memStart = gcinfo() bench.memory_start = gcinfo()
activeBench.timeStart = os.clock() bench.time_start = os.clock()
return n return n
end end
local function BENCH(name: string, fn: () -> ()) local function BENCH(name: string, fn: () -> ())
assert(not activeBench, "cannot run benchmark, a benchmark is already in progress") local active = bench
assert(not active, "a benchmark is already in progress")
local bench: Bench = {} bench = {}; assert(bench)
activeBench = bench
local memStart = gcinfo() ;(collectgarbage :: any)("collect")
local timeStart = os.clock()
local msg: string? local mem_start = gcinfo()
local success = xpcall(fn, function(m: string) msg = m .. debug.traceback("", 2) end) local time_start = os.clock()
local timeStop = os.clock() local err_msg: string?
local memStop = gcinfo()
local success = xpcall(fn, function(m: string)
err_msg = m .. debug.traceback(nil, 2)
end)
local time_stop = os.clock()
local mem_stop = gcinfo()
if not success then if not success then
print("[\27[41;1mERROR\27[0m] " .. name) print(`{WALL}{color.red("ERROR")}{WALL} {name}`)
print("\27[31;1m" .. "error: " .. msg :: string .. "\27[0m") print(color.gray(err_msg :: string))
activeBench = nil else
return time_start = bench.time_start or time_start
mem_start = bench.memory_start or mem_start
local n = bench.iterations or 1
local d, d_unit = convert_units("s", (time_stop - time_start) / n)
local a, a_unit = convert_units("B", math.floor((mem_stop - mem_start) / n * 1e3))
local function round(x: number): string
return x > 0 and x < 10 and (x - math.floor(x)) > 0
and string.format("%2.1f", x)
or string.format("%3.f", x)
end
print(string.format(
`%s %s %s %s{WALL} %s`,
color.gray(tostring(round(d))),
d_unit,
color.gray(tostring(round(a))),
a_unit,
color.gray(name)
))
end end
timeStart = bench.timeStart or timeStart bench = nil
memStart = bench.memStart or memStart
local n = bench.iterations or 1
local duration = timeStop - timeStart
local allocated = memStop - memStart
print(string.format("[ %.3f us | %4.0f B ] %s", duration/n * 1e6, allocated/n * 1e3, name))
activeBench = nil
end end
------------------------------------------------------------------------------------------ --------------------------------------------------------------------------------
-- Printing -- Printing
------------------------------------------------------------------------------------------ --------------------------------------------------------------------------------
local function printa(v: unknown) local function print2(v: unknown)
type Buffer = { n: number, [number]: string } type Buffer = { n: number, [number]: string }
-- overkill concatenationless string buffer -- overkill concatenationless string buffer
@ -291,31 +384,67 @@ local function printa(v: unknown)
print(table.concat(str)) print(table.concat(str))
end end
printa "string" --------------------------------------------------------------------------------
-- Equality
--------------------------------------------------------------------------------
printa(1) local function shallow_eq(a: {}, b: {}): boolean
if #a ~= #b then return false end
printa { for i, v in next, a do
hello = 1, if b[i] ~= v then
bye = "ok", return false
end
end
test = { for i, v in next, b do
1, 2, 3 if a[i] ~= v then
} return false
} end
end
------------------------------------------------------------------------------------------ return true
end
local function deep_eq(a: {}, b: {}): boolean
if #a ~= #b then return false end
for i, v in next, a do
if type(b[i]) == "table" and type(v) == "table" then
if deep_eq(b[i], v) == false then return false end
elseif b[i] ~= v then
return false
end
end
for i, v in next, b do
if type(a[i]) == "table" and type(v) == "table" then
if deep_eq(a[i], v) == false then return false end
elseif a[i] ~= v then
return false
end
end
return true
end
--------------------------------------------------------------------------------
-- Return -- Return
------------------------------------------------------------------------------------------ --------------------------------------------------------------------------------
return { return {
getUnitTestTools = function() test = function()
return TEST, CASE, CHECK, FINISH return TEST, CASE, CHECK, FINISH, SKIP
end, end,
getBenchmarkTools = function() benchmark = function()
return BENCH, START return BENCH, START
end, end,
printa = printa print2 = print2,
seq = shallow_eq,
deq = deep_eq,
color = color
} }

View file

@ -2,7 +2,7 @@
-- unit.lua -- unit.lua
---------------------------------------------------------------------------------------------------------------------- ----------------------------------------------------------------------------------------------------------------------
local TEST, CASE, CHECK, FINISH = require("test/testkit").getUnitTestTools() local TEST, CASE, CHECK, FINISH, SKIP = require("test/testkit").test()
local mock = require "test/mock" local mock = require "test/mock"
local Signal = require "test/goodsignal" local Signal = require "test/goodsignal"
@ -17,19 +17,23 @@ local function gc(n: number?)
end end
end end
local function weak<T>(t: T & {}): T
setmetatable(t :: {}, { __mode = "kv" })
return t
end
-- weak reference table used for gc tests -- weak reference table used for gc tests
local wref = setmetatable({}, { __mode = "kv" }) :: any --local wref = setmetatable({}, { __mode = "kv" }) :: any
TEST("graph", function() TEST("graph", function()
local graph = require "src/graph" local graph = require "src/graph"
local create = graph.create local create = graph.create
local get = graph.get local get = graph.get
local unwrap = graph.unwrap
local set = graph.set local set = graph.set
local capture = graph.capture local capture = graph.capture
local captureAndLink = graph.captureAndLink local capture_and_link = graph.capture_and_link
local link = graph.link local link = graph.link
local setEffect = graph.setEffect local set_effect = graph.set_effect
do CASE "Create" do CASE "Create"
local node = create(1) local node = create(1)
@ -47,8 +51,8 @@ TEST("graph", function()
do CASE "Capture" do CASE "Capture"
local node1 = create(nil) local node1 = create(nil)
local node2 = create(nil) local node2 = create(nil)
local nodes = capture(function(from) local nodes = capture(function()
return from(node1), from(node2) return get(node1), get(node2)
end) end)
CHECK(nodes[1] == node1) CHECK(nodes[1] == node1)
CHECK(nodes[2] == node2) CHECK(nodes[2] == node2)
@ -56,7 +60,7 @@ TEST("graph", function()
do CASE "Link" do CASE "Link"
local parent = create(1) local parent = create(1)
local child = create(nil) local child = create(0)
link(parent, child, function() link(parent, child, function()
return get(parent) return get(parent)
end) end)
@ -68,14 +72,15 @@ TEST("graph", function()
local parent = create(1) local parent = create(1)
local child = create(nil :: any) local child = create(nil :: any)
captureAndLink(child, function(from) capture_and_link(child, function()
return tostring(from(parent)) return tostring(get(parent))
end) end)
set(parent, get(parent) + 1) set(parent, get(parent) + 1)
CHECK(get(child) == "2") CHECK(get(child) == "2")
end end
--[[
do CASE "Scoped captures" do CASE "Scoped captures"
-- table.find but uses `rawequal` since nodes have overloaded __eq metamethod -- table.find but uses `rawequal` since nodes have overloaded __eq metamethod
local function rawfind(t, x) local function rawfind(t, x)
@ -100,52 +105,54 @@ TEST("graph", function()
CHECK(not rawfind(captures, a)) CHECK(not rawfind(captures, a))
CHECK(rawfind(captures, b)) CHECK(rawfind(captures, b))
-- repeat for `captureAndLink` -- repeat for `capture_and_link`
local c = graph.create(1) local c = graph.create(1)
set(a, get(a) + 1) -- mark `b` for recomputation again set(a, get(a) + 1) -- mark `b` for recomputation again
captureAndLink(c, function(from) return from(b) end) capture_and_link(c, function(from) return from(b) end)
-- check that only `b` was linked -- check that only `b` was linked
CHECK(not rawfind(assert(a.__children), c)) CHECK(not rawfind(assert(a.__children), c))
CHECK(rawfind(assert(b.__children), c)) CHECK(rawfind(assert(b.__children), c))
end end
]]
do CASE "Nodes garbage collection" do CASE "Nodes garbage collection"
local node = create(1) :: Node? local wref
wref.node, node = node, nil do
wref = weak { create(1) }
end
gc() gc()
CHECK(not wref.node) CHECK(not wref[1])
end end
do CASE "Node effect garbage collection" do CASE "Node effect garbage collection"
local wref
do do
local function factory(p) -- factory function to prevent closure caching local function factory(p) -- factory function to prevent closure caching
return function() return function()
return unwrap(p) return get(p)
end end
end end
local node = create(1) local node = create(1)
local effect1 = factory(node)
local effect2 = factory(node) do
wref.node = node local effect1 = factory(node)
wref.effect1 = effect1 local effect2 = factory(node)
wref.effect2 = effect2
local t = {} wref = weak { effect1, effect2, node :: any }
setEffect(node, effect1, t)
setEffect(node, effect2, true)
t = nil :: any set_effect(node, effect1, {})
effect1, effect2 = nil :: any, nil :: any set_effect(node, effect2, true)
end
gc() gc()
CHECK(not wref.effect1) -- effect1 should gc since nothing is referencing table `t` CHECK(not wref[1]) -- effect1 should gc since nothing is referencing table `t`
CHECK(wref.effect2) -- effect2 should not gc as `true` is not garbage collectable CHECK(wref[2]) -- effect2 should not gc as `true` is not garbage collectable
end end
gc() gc()
CHECK(not wref.node and not wref.effect2) -- node should now gc along with effect2 CHECK(not wref[3] and not wref[2]) -- node should now gc along with effect2
do -- same test but for multiple nodes referenced by watcher do -- same test but for multiple nodes referenced by watcher
local function factory(a, b) local function factory(a, b)
@ -162,8 +169,8 @@ TEST("graph", function()
wref.effect = effect wref.effect = effect
local t1 = {} local t1 = {}
setEffect(node1, effect, t1) set_effect(node1, effect, t1)
setEffect(node2, effect, t1) set_effect(node2, effect, t1)
t1 = nil :: any t1 = nil :: any
effect = nil :: any effect = nil :: any
@ -182,171 +189,69 @@ end)
TEST("wrap()", function() TEST("wrap()", function()
local wrap = vide.wrap local wrap = vide.wrap
local unwrap = vide.unwrap
local wrapped = vide.wrapped
local watch = vide.watch local watch = vide.watch
do CASE "Wrap value" do CASE "Wrap value"
local state = wrap(1) local state = wrap(1)
CHECK(wrapped(state)) CHECK(state() == 1)
CHECK(unwrap(state) == 1)
end end
do CASE "Setter" do CASE "Setter"
local state, set = wrap(1) local state = wrap(1)
set(2) -- set directly state(2) -- set directly
CHECK(unwrap(state) == 2) CHECK(state() == 2)
set(function(x) return x + 1 end) -- set via function
CHECK(unwrap(state) == 3)
set((wrap(4))) -- set using value of another state
CHECK(unwrap(state) == 4)
end end
do CASE "Does not update if same value" do CASE "Does not update if same value"
local state, set = wrap(1) local state = wrap(1)
local updates = -1 local updates = -1
watch(function(from) watch(function()
from(state) state()
updates += 1 updates += 1
end) end)
set(1) state(1)
CHECK(unwrap(state) == 1)
CHECK(updates == 0) CHECK(updates == 0)
set(1, true) state(2)
CHECK(updates == 1) CHECK(updates == 1)
end end
do CASE "Does not rewrap state"
local state = wrap((wrap(1)))
CHECK(not wrapped(unwrap(state)))
end
end)
TEST("unwrap()", function()
local wrap = vide.wrap
local unwrap = vide.unwrap
do CASE "Gets state value"
local a = wrap(1)
CHECK(unwrap(a) == 1)
end
do CASE "Allow passthrough of non-state"
CHECK(unwrap(5) == 5)
end
end)
TEST("wrapped()", function()
local wrap = vide.wrap
local wrapped = vide.wrapped
do CASE "Check if value is a state object"
local state = wrap()
CHECK(wrapped(state))
end
do CASE "Refuse non-state"
CHECK(not wrapped(0))
end
end) end)
TEST("derive()", function() TEST("derive()", function()
local wrap = vide.wrap local wrap = vide.wrap
local unwrap = vide.unwrap
local derive = vide.derive local derive = vide.derive
do CASE "Derive new value on state change" do CASE "Derive new value on state change"
local state, set = wrap(1) local state = wrap(1)
local derived = derive(function(from) local derived = derive(function()
return tostring(from(state)) return tostring(state())
end) end)
CHECK(unwrap(derived) == "1") -- check initial run during detection CHECK(derived() == "1") -- check initial run during detection
set(function(x) return x + 1 end) state(state() + 1)
CHECK(unwrap(derived) == "2") -- check actually updates CHECK(derived() == "2") -- check updates
end
do CASE "Shorthand derivation"
do
local count, set = wrap(1 :: any)
local text = "Count: " .. count
CHECK(unwrap(text) == "Count: 1")
set(2)
CHECK(unwrap(text) == "Count: 2")
end
do
local count, set = wrap(1 :: any)
local text = count .. "x"
CHECK(unwrap(text) == "1x")
set(2)
CHECK(unwrap(text) == "2x")
end
do
local count, set = wrap(1 :: any)
local text = count .. count
CHECK(unwrap(text) == "11")
set(2)
CHECK(unwrap(text) == "22")
end
do
local a, seta = wrap { b = { c = 1 } }
local b = a.b
local c = b.c
CHECK(unwrap(c) == 1)
seta { b = { c = 2 } }
CHECK(unwrap(c) == 2)
end
do
local state, set = wrap { profiles = { decimal = { level = 1 }}}
local stringified = "Level: " .. state.profiles.decimal.level
set(function(state)
state.profiles.decimal.level += 1
return state
end, true)
CHECK(unwrap(stringified) == "Level: 2")
end
end end
do CASE "Derive from updated" do CASE "Derive from updated"
do do
local a, set = wrap(1) local a = wrap(1)
local b = derive(function(from) local b = derive(function()
return from(a) + 1 return a() + 1
end) end)
set(2) a(2)
local c = derive(function(from) local c = derive(function()
return from(b) + 1 return b() + 1
end) end)
CHECK(unwrap(c) == 4) CHECK(c() == 4)
end
do
local a, set = wrap(1)
local b = a + 1
set(2)
local c = b + 1
CHECK(unwrap(c) == 4)
end end
end end
--[[
do CASE "Cleanup" do CASE "Cleanup"
local count, set = wrap(1) local count, set = wrap(1)
@ -356,69 +261,67 @@ TEST("derive()", function()
v.Destroyed = true v.Destroyed = true
end) end)
local first = unwrap(derived) local first = derived()
CHECK(first.Destroyed == false) CHECK(first.Destroyed == false)
set(2) set(2)
local _ = unwrap(derived) -- trigger recalc local _ = derived() -- trigger recalc
CHECK(first.Destroyed == true) CHECK(first.Destroyed == true)
end end
]]
do CASE "Garbage collection" do CASE "Garbage collection"
do -- check that `derived` does not allow gc of `state` do -- check that `b` does not allow gc of `a`
local state = wrap(1) :: State? local a = wrap(1)
local _derived = derive(function(from) local _b = derive(function()
return from(state) return a()
end) end)
wref.state, state = state, nil local wref = weak { a }
a = nil :: any
gc() gc()
CHECK(not wref.state)
CHECK(not wref[1])
end end
do -- check that `state` allows gc of `derived` do -- check that `a` allows gc of `b`
local state = wrap(1) local a = wrap(1)
local derived = derive(function(from) local b = derive(function()
return from(state) return a()
end) :: State? end)
wref.derived, derived = derived, nil local wref = weak { b }
b = nil :: any
gc() gc()
CHECK(not wref.derived) CHECK(not wref[1])
end end
end end
do CASE "Garbage collection 2" do CASE "Garbage collection 2"
-- creats a chain `a -> b -> c` where `a` is the root -- creats a chain `a -> b -> c` where `a` is the root
local function setup() local function setup()
local weak = setmetatable({}, { __mode = "v" }) local a = wrap(1)
local a, setA = wrap(1) local b = derive(function()
return a()
local b = derive(function(from)
return from(a)
end) end)
local c = derive(function(from) local c = derive(function()
return from(b) return b()
end) end)
weak.a = a return weak { a, b, c }, a, b, c
weak.b = b
weak.c = c
return weak, a, b, c, setA
end end
do -- check that b and c can gc if a is referenced do -- check that b and c can gc if a is referenced
local weak, _a = setup() local wref, _a = setup()
gc() gc()
CHECK(not weak.b) CHECK(not wref[2])
CHECK(not weak.c) CHECK(not wref[3])
end end
do -- check that a and b wont gc if c is referenced do -- check that a and b wont gc if c is referenced
@ -427,21 +330,21 @@ TEST("derive()", function()
_a, _b = nil :: any, nil :: any _a, _b = nil :: any, nil :: any
gc() gc()
CHECK(weak.a) CHECK(weak[1])
CHECK(weak.b) CHECK(weak[2])
end end
do -- check that b wont gc if a and c are referenced do -- check that b wont gc if a and c are referenced
local weak, _a, _b, c, setA = setup() local weak, a, _b, c = setup()
_b = nil :: any _b = nil :: any
gc() gc()
setA(2) a(2)
CHECK(weak.b) CHECK(weak[2])
CHECK(unwrap(c) == 2) CHECK(c() == 2)
end end
end end
end) end)
@ -451,54 +354,54 @@ TEST("watch()", function()
local watch = vide.watch local watch = vide.watch
do CASE "Capture states" do CASE "Capture states"
local a, setA = wrap(1) local a = wrap(1)
local b, setB = wrap(1) local b = wrap(1)
local runcount = 0 local runcount = 0
watch(function(from) watch(function()
local _ = from(a) + from(b) a()
b()
runcount += 1 runcount += 1
end) end)
CHECK(runcount == 1) -- immediate callback execution CHECK(runcount == 1) -- immediate callback execution
setA(2) a(2)
CHECK(runcount == 2) CHECK(runcount == 2)
setB(2) b(2)
CHECK(runcount == 3) CHECK(runcount == 3)
end end
do CASE "Stop watch" do CASE "Stop watch"
local a, setA = wrap(1) local a = wrap(1)
local b, setB = wrap(1) local b = wrap(1)
local runcount = 0 local runcount = 0
local unwatch = watch(function(from) local unwatch = watch(function()
local _ = from(a) + from(b) a()
runcount += 1 runcount += 1
end) end)
CHECK(runcount == 1) CHECK(runcount == 1)
unwatch() unwatch()
setA(2) a(2)
setB(2)
CHECK(runcount == 1) CHECK(runcount == 1)
end end
do CASE "Side-effect cleanup" do CASE "Side-effect cleanup"
local state, set = wrap(1) local state = wrap(1)
local effect_runcount = 0 local effect_runcount = 0
local cleanup_runcount = 0 local cleanup_runcount = 0
local unwatch = watch(function(from) local unwatch = watch(function()
local _ = from(state) state()
effect_runcount += 1 effect_runcount += 1
return function() cleanup_runcount += 1 end return function() cleanup_runcount += 1 end
end) end)
CHECK(effect_runcount == 1) CHECK(effect_runcount == 1)
CHECK(cleanup_runcount == 0) CHECK(cleanup_runcount == 0)
set(2) state(2)
CHECK(effect_runcount == 2) CHECK(effect_runcount == 2)
CHECK(cleanup_runcount == 1) CHECK(cleanup_runcount == 1)
unwatch() unwatch()
@ -508,22 +411,24 @@ TEST("watch()", function()
do CASE "Garbage collection" do CASE "Garbage collection"
local function factory(p) local function factory(p)
return function(from: any) return function()
from(p) p()
end end
end end
do -- state prevents gc of watcher do -- state prevents gc of watcher
local state = wrap(1) local state = wrap(1)
local effect = factory(state)
watch(effect)
wref.effect = effect local wref
effect = nil :: any
do
local effect = factory(state)
watch(effect)
wref = { effect }
end
gc() gc()
CHECK(wref.effect) -- should still exist CHECK(wref[1]) -- should still exist
end end
@ -533,42 +438,42 @@ TEST("watch()", function()
local unwatch = watch(effect) local unwatch = watch(effect)
wref.effect = effect local wref = weak { effect }
effect = nil :: any effect = nil :: any
gc() gc()
CHECK(wref.effect) -- should still exist CHECK(wref[1]) -- should still exist
unwatch(); unwatch = nil :: any unwatch(); unwatch = nil :: any
gc() gc()
CHECK(not wref.effect) -- should gc CHECK(not wref[1]) -- should gc
end end
do -- state can gc with watcher do -- state can gc with watcher
local state = wrap(1) local wref
local effect = factory(state)
watch(effect)
wref.state = state do
state = nil :: any local state = wrap(1)
local effect = factory(state)
effect = nil :: any watch(effect)
wref = weak { state }
end
gc() gc()
CHECK(not wref.state) -- should gc
CHECK(not wref[1]) -- should gc
end end
do -- watcher can gc if no state captured do -- watcher can gc if no state captured
local effect = factory() local effect = factory(function() end)
watch(effect) watch(effect)
wref.effect = effect local wref = weak { effect }
effect = nil :: any effect = nil :: any
gc() gc()
CHECK(not wref.effect) -- should gc CHECK(not wref[1]) -- should gc
end end
end end
end) end)
@ -576,7 +481,6 @@ end)
TEST("create()", function() TEST("create()", function()
local create = vide.create local create = vide.create
local wrap = vide.wrap local wrap = vide.wrap
local Children: "Children" = vide.Children :: "Children"
do CASE "Apply default properties" do CASE "Apply default properties"
local defaults = require("src/defaults") local defaults = require("src/defaults")
@ -601,15 +505,13 @@ TEST("create()", function()
do CASE "Assign children" do CASE "Assign children"
local frame = create "Frame" { local frame = create "Frame" {
[Children] = { create "TextLabel" { Name = "A" } :: any,
create "TextLabel" { Name = "A" } :: any, create "TextLabel" { Name = "B" },
create "TextLabel" { Name = "B" }, {
create "TextLabel" { Name = "C" } :: any,
create "TextLabel" { Name = "D" },
{ {
create "TextLabel" { Name = "C" } :: any, create "TextLabel" { Name = "E" }
create "TextLabel" { Name = "D" },
{
create "TextLabel" { Name = "E" }
}
} }
} }
} }
@ -620,14 +522,14 @@ TEST("create()", function()
CHECK(frame:FindFirstChild "E") CHECK(frame:FindFirstChild "E")
local image = create "ImageLabel" { local image = create "ImageLabel" {
[Children] = create "TextLabel" { Name = "A" } create "TextLabel" { Name = "A" }
} }
CHECK(image:FindFirstChild "A") CHECK(image:FindFirstChild "A")
end end
do CASE "Binding properties to state" do CASE "Binding properties to state"
local name, setName = wrap("Hi") local name = wrap("Hi")
local text, setText = wrap("Bye") local text = wrap("Bye")
local label = create "TextLabel" { local label = create "TextLabel" {
Name = name, Name = name,
@ -637,8 +539,8 @@ TEST("create()", function()
CHECK(label.Name == "Hi") CHECK(label.Name == "Hi")
CHECK(label.Text == "Bye") CHECK(label.Text == "Bye")
setName "Foo" name "Foo"
setText "Bar" text "Bar"
CHECK(label.Name == "Foo") CHECK(label.Name == "Foo")
CHECK(label.Text == "Bar") CHECK(label.Text == "Bar")
@ -648,14 +550,14 @@ TEST("create()", function()
do -- instance should gc despite property bound to state do -- instance should gc despite property bound to state
local state = wrap("Hi") local state = wrap("Hi")
do local wref = weak {
wref.instance = create "TextLabel" { create "TextLabel" {
Text = state, Text = state,
} }
end }
gc() gc()
CHECK(not wref.instance) CHECK(not wref[1])
end end
do -- instance should NOT gc despite property bound to state when parented do -- instance should NOT gc despite property bound to state when parented
@ -663,45 +565,47 @@ TEST("create()", function()
local parent = create "Frame" {} local parent = create "Frame" {}
do local wref = weak {
wref.instance = create "TextLabel" { create "TextLabel" {
Parent = parent, Parent = parent,
Text = state, Text = state,
} }
end }
gc() gc()
CHECK(wref.instance) CHECK(wref[1])
wref.instance.Parent = nil wref[1].Parent = nil
wref.instance.Parent = parent wref[1].Parent = parent
gc() gc()
CHECK(wref.instance) CHECK(wref[1])
wref.instance:Destroy() wref[1]:Destroy()
gc() gc()
CHECK(not wref.instance) CHECK(not wref[1])
end end
do -- state should not gc once exits scope while instance still exists do -- state should not gc once exits scope while instance still exists
local _label local _label
local wref
do do
local state = wrap("Hi") local state = wrap("Hi")
wref.state = state wref = weak { state }
_label = create "TextLabel" { _label = create "TextLabel" {
Name = state, Name = state,
} }
end end
gc() gc()
CHECK(wref.state) CHECK(wref[1])
end end
do -- state and instance should gc once both exit scope do -- state and instance should gc once both exit scope
local wref
do do
local text = wrap("Hi") local text = wrap("Hi")
@ -709,16 +613,16 @@ TEST("create()", function()
Text = text, Text = text,
} }
wref.text = text wref = weak { text = text, box = box}
wref.box = box
end end
gc() gc()
CHECK(not wref.state) CHECK(not wref.text)
CHECK(not wref.box) CHECK(not wref.box)
end end
--[[
do -- binding should gc despite state still existing after instance is gc do -- binding should gc despite state still existing after instance is gc
local state = wrap("Hi") local state = wrap("Hi")
@ -727,8 +631,13 @@ TEST("create()", function()
Text = state, Text = state,
} }
local wref = {
instance = instance,
brinding = next((state :: any).effects
}
wref.instance = instance wref.instance = instance
wref.binding = next((state :: any).__effects) wref.binding = )
end end
CHECK(wref.binding) CHECK(wref.binding)
@ -737,11 +646,11 @@ TEST("create()", function()
CHECK(not wref.instance) CHECK(not wref.instance)
CHECK(not wref.binding) CHECK(not wref.binding)
end end]]
end end
do CASE "Bind same state to multiple instance properties" do CASE "Bind same state to multiple instance properties"
local state, set = wrap "1" local state = wrap "1"
local text = create "TextBox" { local text = create "TextBox" {
Name = state, Name = state,
@ -749,7 +658,7 @@ TEST("create()", function()
PlaceholderText = state PlaceholderText = state
} }
set "2" state "2"
CHECK(text.Name == "2") CHECK(text.Name == "2")
CHECK(text.Text == "2") CHECK(text.Text == "2")
@ -757,7 +666,7 @@ TEST("create()", function()
end end
do CASE "Bind children" do CASE "Bind children"
local state, set = wrap({} :: {}?) local state = wrap({} :: {}?)
local a, b, c = local a, b, c =
create "TextLabel" { Name = "A" }, create "TextLabel" { Name = "A" },
@ -765,31 +674,32 @@ TEST("create()", function()
create "TextLabel" { Name = "C" } create "TextLabel" { Name = "C" }
local frame = create "Frame" { local frame = create "Frame" {
[Children] = state state
} }
set { a, b } state { a, b }
CHECK(frame:FindFirstChild "A") CHECK(frame:FindFirstChild "A")
CHECK(frame:FindFirstChild "B") CHECK(frame:FindFirstChild "B")
-- check that b is removed and c is added while a remains untouched -- check that b is removed and c is added while a remains untouched
set { a, c } state { a, c }
CHECK(frame:FindFirstChild "A") CHECK(frame:FindFirstChild "A")
CHECK(frame:FindFirstChild "C") CHECK(frame:FindFirstChild "C")
CHECK(not frame:FindFirstChild "B") CHECK(not frame:FindFirstChild "B")
set(nil) state(nil)
CHECK(#frame:GetChildren() == 0) CHECK(#frame:GetChildren() == 0)
end end
--[[
do CASE "Parent set to nil by state does not allow gc" do CASE "Parent set to nil by state does not allow gc"
-- this is technically a bug but we test for this anyways to confirm behavior -- this is technically a bug but we test for this anyways to confirm behavior
local parent = create "Frame" { Name = "Parent" } local parent = create "Frame" { Name = "Parent" }
local state, set = wrap(parent :: Frame?) local state = wrap(parent :: Frame?)
do do
wref.child = create "Frame" { Parent = state, Name = "Child" } :: Frame? wref.child = create "Frame" { Parent = state, Name = "Child" } :: Frame?
@ -808,6 +718,7 @@ TEST("create()", function()
gc() gc()
CHECK(not wref.child) CHECK(not wref.child)
end end
]]
do CASE "GC test" do CASE "GC test"
local data = setmetatable({}, {}) local data = setmetatable({}, {})
@ -872,9 +783,9 @@ TEST("map()", function()
return tostring(v) return tostring(v)
end) end)
local t = unwrap(derived) local t = derived()
for i, v in next, unwrap(state) do for i, v in next, state() do
CHECK(tostring(v) == t[i]) CHECK(tostring(v) == t[i])
end end
end end
@ -889,10 +800,10 @@ TEST("map()", function()
return tostring(v) return tostring(v)
end) end)
local _ = unwrap(derived) -- trigger evaluation (so the next set is forced to be re-calculated) local _ = derived() -- trigger evaluation (so the next set is forced to be re-calculated)
set { 1, 2, 4 } set { 1, 2, 4 }
local t = unwrap(derived) local t = derived()
CHECK(t[1] == "1") CHECK(t[1] == "1")
CHECK(t[2] == "2") CHECK(t[2] == "2")
@ -910,10 +821,10 @@ TEST("map()", function()
return tostring(v) return tostring(v)
end) end)
local _ = unwrap(derived) -- trigger evaluation (so the next set is forced to be re-calculated) local _ = derived() -- trigger evaluation (so the next set is forced to be re-calculated)
set { 1, 2 } set { 1, 2 }
local t = unwrap(derived) local t = derived()
CHECK(t[1] == "1") CHECK(t[1] == "1")
CHECK(t[2] == "2") CHECK(t[2] == "2")
@ -962,13 +873,13 @@ TEST("map()", function()
v.Destroyed = true v.Destroyed = true
end) end)
local first = unwrap(derived) local first = derived()
CHECK(first[1].Destroyed == false) CHECK(first[1].Destroyed == false)
set { 1, 2, 4 } set { 1, 2, 4 }
local _ = unwrap(derived) local _ = derived()
CHECK(first[1].Destroyed == false) CHECK(first[1].Destroyed == false)
CHECK(first[2].Destroyed == false) CHECK(first[2].Destroyed == false)
@ -1033,10 +944,10 @@ TEST("spring()", function()
local springed = spring(number, 1, 1) local springed = spring(number, 1, 1)
set(20) set(20)
CHECK(unwrap(springed) == 10) CHECK(springed() == 10)
vide.step(1/60) vide.step(1/60)
CHECK(unwrap(springed) ~= 10) CHECK(springed() ~= 10)
CHECK(unwrap(springed) > 10) CHECK(springed() > 10)
end end
do CASE "Garbage collection" do CASE "Garbage collection"
@ -1080,23 +991,6 @@ TEST("spring()", function()
end end
end) end)
TEST("Layout", function()
local create = vide.create
local Layout = vide.Layout
do CASE "Apply layout properties"
local frame = create "Frame" {
[Layout] = {
AnchorPoint = Vector2.new(0, 0.5),
Position = UDim2.fromScale(0.5, 0.5)
}
}
CHECK(frame.AnchorPoint == Vector2.new(0, 0.5))
CHECK(frame.Position == UDim2.fromScale(0.5, 0.5))
end
end)
TEST("Event", function() TEST("Event", function()
local create = vide.create local create = vide.create
local Event = vide.Event local Event = vide.Event
@ -1199,6 +1093,7 @@ TEST("Changed", function()
end end
end) end)
--[[
TEST("Created", function() TEST("Created", function()
local create = vide.create local create = vide.create
local Created = vide.Created local Created = vide.Created
@ -1218,7 +1113,7 @@ TEST("Created", function()
CHECK(ran) CHECK(ran)
end end
end) end)]]
TEST("strict", function() TEST("strict", function()
vide.strict = true vide.strict = true
@ -1265,7 +1160,7 @@ TEST("strict", function()
CHECK(runcount == 2) CHECK(runcount == 2)
set(2) set(2)
local _ = unwrap(derived) local _ = derived()
CHECK(runcount == 4) CHECK(runcount == 4)
end end