This commit is contained in:
aaron 2023-09-10 21:32:08 +01:00
parent 866135b152
commit e776183452
10 changed files with 480 additions and 367 deletions

View file

@ -138,7 +138,7 @@ local function apply<T>(instance: T & Instance, properties: { [unknown]: unknown
-- finally set parent if any -- finally set parent if any
if parent then if parent then
if type(parent) == "function" then if type(parent) == "function" then
error("cannot set parent to state") bind.parent(instance, parent :: () -> ())
else else
instance.Parent = parent :: Instance instance.Parent = parent :: Instance
end end

View file

@ -6,10 +6,11 @@ local flags = require(script.Parent.flags)
local graph = require(script.Parent.graph) local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T> type Node<T> = graph.Node<T>
local create_node = graph.create_node local create_node = graph.create_node
local get_scope = graph.get_scope
local open_scope = graph.open_scope local open_scope = graph.open_scope
local close_scope = graph.close_scope local close_scope = graph.close_scope
local track = graph.track
local add_child = graph.add_child local add_child = graph.add_child
local capture_parents = graph.capture_parents
--[[ --[[
@ -70,9 +71,13 @@ function bind(instance: Instance, property: string, setter: (Instance) -> ())
local binding = create_node(instance) local binding = create_node(instance)
binding.effect = setter binding.effect = setter
open_scope(binding.scope) local owner = get_scope()
assert(owner)
capture_parents(binding, setter :: () -> any, instance) open_scope(binding)
track(owner)
setter(instance)
close_scope() close_scope()
end end
@ -84,12 +89,7 @@ local function bind_property(instance: Instance, property: string, fn: () -> unk
end end
local function bind_parent(instance: Instance, fn: () -> Instance?) local function bind_parent(instance: Instance, fn: () -> Instance?)
instance.Destroying:Connect(function()
instance = nil :: any -- allow gc when destroyed
end)
bind(instance, "Parent", function(instance) bind(instance, "Parent", function(instance)
local _ = instance -- state will strongly reference instance when parent is bound
instance.Parent = fn() instance.Parent = fn()
end) end)
end end

View file

@ -2,7 +2,6 @@ if not game then script = require "test/relative-string" end
local graph = require(script.Parent.graph) local graph = require(script.Parent.graph)
local create_node = graph.create_node local create_node = graph.create_node
local capture_parents = graph.capture_parents
local add_child = graph.add_child local add_child = graph.add_child
local update = graph.update local update = graph.update
local track = graph.track local track = graph.track
@ -11,17 +10,19 @@ local open_scope = graph.open_scope
local close_scope = graph.close_scope local close_scope = graph.close_scope
local function derive<T>(fn: () -> T): () -> T local function derive<T>(fn: () -> T): () -> T
assert(get_scope()) local owner = get_scope()
assert(owner)
local node = create_node((false :: any) :: T) local node = create_node((false :: any) :: T)
node.effect = function() node.effect = function()
node.cache = fn() node.cache = fn()
update(node)
end end
open_scope(node.scope) add_child(owner, node)
node.cache = capture_parents(node, fn) open_scope(node)
node.cache = fn()
close_scope() close_scope()

View file

@ -11,13 +11,16 @@ export type StartNode<T> = {
export type Node<T> = { export type Node<T> = {
cache: T, cache: T,
children: { [Node<T>]: true } | false, children: { [Node<T>]: true } | false,
effect: (T) -> (), effect: (T) -> () | false,
cleanups: { () -> () } | false, cleanups: { () -> () } | false,
} }
local active = {} :: { [Node<any>]: true }
local scopes = { n = 0 } :: { [number]: Node<any>, n: number } local scopes = { n = 0 } :: { [number]: Node<any>, n: number }
local WEAK_VALUES = { __mode = "v" } local WEAK_VALUES = { __mode = "v" }
local WEAK_KEYS = { __mode = "k" }
local EVALUATION_ERR = "error while evaluating source:\n\n" local EVALUATION_ERR = "error while evaluating source:\n\n"
-- runs a given callback in a context that Luau does not allow yielding in -- runs a given callback in a context that Luau does not allow yielding in
@ -46,34 +49,27 @@ local check_for_yield: <T...>(fn: (T...) -> unknown, T...) -> () do
end end
end end
local function get_scope(): Node<unknown> local function get_stack_scope(offset: number): Node<unknown>?
return scopes[scopes.n - offset]
end
local function get_scope(): Node<unknown>?
return scopes[scopes.n] return scopes[scopes.n]
end end
local function add_child<T>(parent: Node<any>, child: Node<any>) local function add_child<T>(parent: StartNode<any>, child: Node<any>)
if parent.children then if parent.children then
parent.children[child] = true parent.children[child] = true
else else
parent.children = { [child] = true :: true } parent.children = { [child] = true :: true }
setmetatable(parent.children :: any, WEAK_KEYS) -- todo:
end end
end end
-- local function open_root_scope<T>(node: Node<T>)
-- assert(not scopes[1])
-- local n = scopes.n + 1
-- scopes.n = n
-- scopes[n] = node
-- end
local function open_scope<T>(node: Node<T>) local function open_scope<T>(node: Node<T>)
local n = scopes.n + 1 local n = scopes.n + 1
scopes.n = n scopes.n = n
scopes[n] = node scopes[n] = node
-- local parent = scopes[n-1]
-- assert(parent)
-- add_child(parent, node)
end end
local function close_scope() local function close_scope()
@ -100,49 +96,71 @@ local function run_cleanups<T>(node: Node<T>)
end end
local function run_effect<T>(node: Node<T>) local function run_effect<T>(node: Node<T>)
node.effect(node.cache) if node.effect then
node.effect(node.cache)
end
end end
local function destroy<T>(node: Node<T>) local function destroy<T>(node: Node<T>)
run_cleanups(node) run_cleanups(node)
for _, child in ipairs(node) do active[node] = nil
destroy(child) if node.children then
for child in node.children do
destroy(child)
end
end end
end end
-- runs node effects, recalculates descendants and runs descendant effects -- runs node effects, recalculates descendants and runs descendant effects
local function update<T>(node: StartNode<T>) local function rec<T>(node: StartNode<T>, update_queue: { Node<any> })
if not node.children then return end if not node.children then return end
local cache = {} for child in next, node.children do
for child in node.children do table.insert(update_queue, child)
table.insert(cache, child) rec(child, update_queue)
end end
for _, child in next, cache do table.clear(node.children)
open_scope(child)
run_cleanups(child)
run_effect(child)
close_scope()
update(child)
end
end end
local function track<T>(node: Node<T>) local function update<T>(node: StartNode<T>)
add_child(node, get_scope()) local update_queue = {} :: { Node<any> }
--assert(#update_queue == 0, "update already in progress")
rec(node, update_queue)
for _, n in next, update_queue do
open_scope(n) -- todo
run_cleanups(n)
run_effect(n)
close_scope()
end
table.clear(update_queue)
end
local function track<T>(node: StartNode<T>)
local scope = get_scope()
assert(scope)
if scope.effect then -- todo
add_child(node, scope)
end
end end
local function create_node<T>(value: T): Node<T> local function create_node<T>(value: T): Node<T>
return { local node: Node<T> = {
cache = value, cache = value,
effect = function() end, effect = false,
cleanups = false, cleanups = false :: false,
children = false children = false :: false
} }
active[node] = true
return node
end end
local function get_children(node: Node<unknown>): { Node<unknown> } local function get_children<T>(node: Node<T>): { Node<unknown> }
if not node.children then return {} end if not node.children then return {} end
local children = {} local children = {}
@ -151,7 +169,7 @@ local function get_children(node: Node<unknown>): { Node<unknown> }
table.insert(children, child) table.insert(children, child)
end end
return children return children :: { Node<any> }
end end
local function create_start_node<T>(value: T): StartNode<T> local function create_start_node<T>(value: T): StartNode<T>
@ -159,10 +177,10 @@ local function create_start_node<T>(value: T): StartNode<T>
end end
return table.freeze { return table.freeze {
open_root_scope = open_root_scope,
open_scope = open_scope, open_scope = open_scope,
close_scope = close_scope, close_scope = close_scope,
get_scope = get_scope, get_scope = get_scope,
get_stack_scope = get_stack_scope,
add_cleanup = add_cleanup, add_cleanup = add_cleanup,
destroy = destroy, destroy = destroy,
run_cleanups = run_cleanups, run_cleanups = run_cleanups,

View file

@ -9,12 +9,8 @@ type Scope = graph.Scope
type StartNode<T> = graph.StartNode<T> type StartNode<T> = graph.StartNode<T>
local create_node = graph.create_node local create_node = graph.create_node
local create_start_node = graph.create_start_node local create_start_node = graph.create_start_node
local create_scope = graph.create_scope
local track = graph.track local track = graph.track
local update = graph.update local update = graph.update
local capture = graph.capture
local capture_parents = graph.capture_parents
local add_child = graph.add_child
local get_scope = graph.get_scope local get_scope = graph.get_scope
local open_scope = graph.open_scope local open_scope = graph.open_scope
local close_scope = graph.close_scope local close_scope = graph.close_scope
@ -33,9 +29,8 @@ end
-- todo: optimize output array -- todo: optimize output array
local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI, K) -> VO): () -> { VO } local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI, K) -> VO): () -> { VO }
assert(get_scope()) local owner = get_scope()
assert(owner)
local root = create_scope()
local input_cache = {} :: Map<K, VI> local input_cache = {} :: Map<K, VI>
local output_cache = {} :: Map<K, VO> local output_cache = {} :: Map<K, VO>
@ -65,7 +60,7 @@ local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI,
table.clear(remove_queue) table.clear(remove_queue)
open_scope(root) open_scope(owner) -- todo: needed?
-- process new or changed values -- process new or changed values
for i, v in next, data do for i, v in next, data do
@ -73,10 +68,11 @@ local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI,
if cv ~= v then if cv ~= v then
if cv == nil then if cv == nil then
local scope = create_scope() local scope = create_node(false)
scopes[i] = scope scopes[i] = scope
open_scope(scope) open_scope(scope)
track(owner)
local node = create_start_node(v) local node = create_start_node(v)
input_nodes[i] = node input_nodes[i] = node
@ -112,9 +108,11 @@ local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI,
update_children(input()) update_children(input())
end end
local value = capture_parents(output, input) open_scope(output)
output.cache = update_children(value) output.cache = update_children(input())
close_scope()
return function() return function()
track(output) track(output)
@ -124,9 +122,8 @@ end
-- todo: optimize output array -- todo: optimize output array
local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () -> K) -> VO): () -> { VO } local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () -> K) -> VO): () -> { VO }
assert(get_scope()) local owner = get_scope()
assert(owner)
local root = create_scope()
local cur_input_cache_up = {} :: Map<VI, K> local cur_input_cache_up = {} :: Map<VI, K>
local new_input_cache_up = {} :: Map<VI, K> local new_input_cache_up = {} :: Map<VI, K>
@ -159,7 +156,7 @@ local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () ->
local cv = cur_input_cache[v] local cv = cur_input_cache[v]
if cv == nil then if cv == nil then
local scope = create_scope() local scope = create_node(false)
scopes[v] = scope scopes[v] = scope
open_scope(scope) open_scope(scope)
@ -213,9 +210,11 @@ local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () ->
update_children(input()) update_children(input())
end end
local value = capture_parents(output, input) open_scope(output)
output.cache = update_children(value) output.cache = update_children(input())
close_scope()
return function() return function()
track(output) track(output)

View file

@ -5,24 +5,24 @@ local throw = require(script.Parent.throw)
local on_gc = require(script.Parent.on_gc)() local on_gc = require(script.Parent.on_gc)()
local graph = require(script.Parent.graph) local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T> type Node<T> = graph.Node<T>
local create_scope = graph.create_scope local create_node = graph.create_node
local open_scope = graph.open_scope local open_scope = graph.open_scope
local close_scope = graph.close_scope local close_scope = graph.close_scope
local get_scope = graph.get_scope local get_scope = graph.get_scope
local destroy = graph.destroy local destroy = graph.destroy
local function root<T>(fn: () -> T): (T, () -> ()) local function root<T>(fn: () -> T): (T, () -> ())
assert(not get_scope()) --assert(not get_scope())
local scope = create_scope() local node = create_node(false)
open_scope(scope) open_scope(node)
local v = fn() local v = fn()
close_scope() close_scope()
return v, function() return v, function()
destroy(scope) destroy(node)
end end
end end

View file

@ -27,8 +27,10 @@ type Node<T> = graph.Node<T>
type StartNode<T> = graph.StartNode<T> type StartNode<T> = graph.StartNode<T>
local create_node = graph.create_node local create_node = graph.create_node
local create_start_node = graph.create_start_node local create_start_node = graph.create_start_node
local get_scope = graph.get_scope
local open_scope = graph.open_scope
local close_scope = graph.close_scope
local update = graph.update local update = graph.update
local capture = graph.capture
local add_child = graph.add_child local add_child = graph.add_child
local track = graph.track local track = graph.track
@ -148,7 +150,18 @@ local springs: { [SpringData<any>]: StartNode<any> } = {}
setmetatable(springs, { __mode = "v" }) setmetatable(springs, { __mode = "v" })
local function spring<T>(source: () -> T, period: number?, damping_ratio: number?): () -> T local function spring<T>(source: () -> T, period: number?, damping_ratio: number?): () -> T
local inputs, initial_value = capture(source) local owner = get_scope()
assert(owner)
local updater = create_node(false)
updater.effect = true :: any -- todo
add_child(owner, updater)
open_scope(updater)
local initial_value = source()
close_scope()
local vtype = typeof(initial_value) local vtype = typeof(initial_value)
@ -178,10 +191,9 @@ local function spring<T>(source: () -> T, period: number?, damping_ratio: number
source_value = initial_value, source_value = initial_value,
} }
local output = create_start_node(initial_value) local output = create_start_node(initial_value)
local updater = create_node(false)
updater.effect = function() updater.effect = function()
local v = source() local v = source()
data.x1_123, data.x1_456 = type_to_vec6[typeof(v)](v) data.x1_123, data.x1_456 = type_to_vec6[typeof(v)](v)
@ -189,10 +201,6 @@ local function spring<T>(source: () -> T, period: number?, damping_ratio: number
springs[data] = output -- todo: investigate why insertion is not O(1) at ~20k springs springs[data] = output -- todo: investigate why insertion is not O(1) at ~20k springs
end end
for _, input in next, inputs do
add_child(input, updater)
end
return function() return function()
track(output) track(output)
return output.cache return output.cache
@ -258,6 +266,7 @@ local function update_spring_sources()
else else
output.cache = vec6_to_type[typeof(data.source_value)](x0_123, x0_456) output.cache = vec6_to_type[typeof(data.source_value)](x0_123, x0_456)
end end
update(output) update(output)
end end

View file

@ -1,20 +1,25 @@
if not game then script = require "test/relative-string" end if not game then script = require "test/relative-string" end
local create = require(script.Parent.create)
local graph = require(script.Parent.graph) local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T> type Node<T> = graph.Node<T>
local refs = graph.refs local create_node = graph.create_node
local get_stack_scope = graph.get_stack_scope
local open_scope = graph.open_scope
local close_scope = graph.close_scope
local non_tracking_scope = create_node(false)
local function untrack<T>(source: () -> T): T local function untrack<T>(source: () -> T): T
local initial = #refs local scope = get_stack_scope(1)
local value = source() open_scope(scope or non_tracking_scope)
-- remove any references made since `untrack()` was called local v = source()
for i = initial, #refs do
refs[i] = nil
end
return value close_scope()
return v
end end
return untrack return untrack

View file

@ -2,20 +2,24 @@ if not game then script = require "test/relative-string" end
local graph = require(script.Parent.graph) local graph = require(script.Parent.graph)
local create_node = graph.create_node local create_node = graph.create_node
local capture_parents = graph.capture_parents
local get_scope = graph.get_scope local get_scope = graph.get_scope
local open_scope = graph.open_scope local open_scope = graph.open_scope
local close_scope = graph.close_scope local close_scope = graph.close_scope
local add_child = graph.add_child
local track = graph.track
local function watch(effect: () -> ()) local function watch(effect: () -> ())
assert(get_scope()) local owner = get_scope()
assert(owner)
local node = create_node(false) local node = create_node(false)
node.effect = effect node.effect = effect
open_scope(node.scope) add_child(owner, node)
capture_parents(node, effect :: () -> any) open_scope(node)
effect()
close_scope() close_scope()
end end

View file

@ -1,13 +1,15 @@
local testkit = require("test/testkit") local testkit = require("test/testkit")
local TEST, CASE, CHECK, FINISH, SKIP = testkit.test() local TEST, CASE, CHECK, FINISH, SKIP = testkit.test()
SKIP"graph"
local mock = require "test/mock" local mock = require "test/mock"
local Instance, Signal = mock.Instance, mock.Signal local Instance, Signal = mock.Instance, mock.Signal
local Vector2, UDim2 = mock.Vector2, mock.UDim2 local Vector2, UDim2 = mock.Vector2, mock.UDim2
local vide = require "src/init" local vide = require "src/init"
local graph = require "src/graph"
type Node<T> = graph.Node<T>
type Map<K, V> = { [K] : V }
local function gc(n: number?) local function gc(n: number?)
for i = 1, n or 3 do for i = 1, n or 3 do
@ -27,26 +29,40 @@ local function wrap_root(fn: () -> ())
end end
end end
local NIL = NIL
TEST("graph", function() TEST("graph", function()
local graph = require "src/graph"
local create_node = graph.create_node local create_node = graph.create_node
local create_start_node = graph.create_start_node
local track = graph.track local track = graph.track
local update = graph.update local update = graph.update
local add_child = graph.add_child local add_child = graph.add_child
local open_root_scope = graph.open_root_scope local get_scope = graph.get_scope
local open_scope = graph.open_scope local open_scope = graph.open_scope
local close_scope = graph.close_scope local close_scope = graph.close_scope
local get_children = graph.get_children local get_children = graph.get_children
local add_cleanup = graph.add_cleanup
local destroy = graph.destroy
do CASE "node creation" local function node<T>(v: T?)
local node = create_node(1) local n = create_node(v or false)
CHECK(node.cache == 1) n.effect = function() end
return n
end
local function scope()
return create_node(false)
end
local function cleanup(fn: () -> ())
local node = assert(get_scope())
add_cleanup(node, fn)
end end
do CASE "link nodes" do CASE "link nodes"
local a = create_node(nil) local a = node()
local b = create_node(nil) local b = node()
local c = create_node(nil) local c = node()
open_scope(c) open_scope(c)
@ -60,9 +76,9 @@ TEST("graph", function()
end end
do CASE "rerun linked nodes" do CASE "rerun linked nodes"
local a = create_node(nil) local a = node()
local b = create_node(nil) local b = node()
local c = create_node(nil) local c = node()
local count = 0 local count = 0
@ -87,53 +103,166 @@ TEST("graph", function()
CHECK(count == 3) CHECK(count == 3)
end end
do CASE "etst" do CASE "case 1"
--[[ -- local function indexes<K, V>(input: Node<Map<K, V>>): Map<K, Node<V>>
-- local root = get_scope()
-- local updated = create_node(false)
-- local scopes = {}
-- local outputs = {}
-- function updated.effect()
-- open_scope(root)
-- for i, v in input do
-- if not scopes[i] then
-- scopes[i] = create_node(false)
-- outputs[i] = create_start_node(v)
-- end
root -- open_scope(scopes[i])
Items -> Indexes() -- outputs[i].cache = v
-- update(outputs[i])
-- close_scope()
-- end
indexes_root -- for i, v in outputs do
v1 + sel -> bind -- if input[i] == nil then
v2 + sel -> bind -- destroy(scopes[i])
-- end
-- end
]] -- close_scope()
local items = create_node { 1, 2 } -- end
local count = 0 -- open_scope(updated)
-- updated.effect(false)
-- close_scope()
-- return outputs
-- end
local function effect() -- construct graph
track(a)
track(b) local items = node { "a", "b" }
count += 1 local selected = node "a"
local root = scope()
local scope1 = scope()
local scope2 = scope()
local items_updated
local bind1
local bind2
local cleaned = {} :: { [any]: any }
local function clean(s)
cleanup(function()
cleaned[s] = true
end)
end end
c.effect = effect do open_scope(root)
clean "root"
items_updated = node()
items_updated.effect = function() end
track(items_updated) -- should not
open_scope(c) add_child(root, items_updated)
do open_scope(items_updated)
track(items)
effect() do open_scope(root)
add_child(root, scope1)
do open_scope(scope1)
clean "scope1"
bind1 = node()
bind1.effect = function() end
close_scope() add_child(scope1, bind1)
do open_scope(bind1)
clean "bind1"
track(selected)
close_scope() end
close_scope() end
add_child(root, scope2)
do open_scope(scope2)
clean "scope2"
bind2 = node()
bind2.effect = function() end
add_child(scope2, bind2)
do open_scope(bind2)
clean "bind2"
track(selected)
close_scope() end
close_scope() end
close_scope() end
close_scope() end
close_scope() end
CHECK(count == 1)
update(a) -- verify graph
CHECK(count == 2)
update(b) do
CHECK(count == 3) local c = get_children(items_updated)
CHECK(#c == 0)
end
do
local c = get_children(root)
CHECK(#c == 3)
CHECK(table.find(c, items_updated))
CHECK(table.find(c, scope1 :: Node<any>))
CHECK(table.find(c, scope2 :: Node<any>))
end
do
local c = get_children(selected)
CHECK(#c == 2)
CHECK(table.find(c, bind1))
CHECK(table.find(c, bind2))
end
do
local c = get_children(scope1)
CHECK(#c == 1)
CHECK(table.find(c, bind1))
end
do
local c = get_children(scope2)
CHECK(#c == 1)
CHECK(table.find(c, bind2))
end
-- destroy
CHECK(table.find(get_children(root), scope1 :: Node<any>))
destroy(scope1)
CHECK(table.find(get_children(root), scope1 :: Node<any>))
CHECK(cleaned.scope1)
CHECK(cleaned.bind1)
scope1 = NIL
bind1 = NIL
bind2 = NIL
gc()
CHECK(#get_children(root) == 2)
CHECK(#get_children(selected) == 1)
end end
-- todo: further tests -- todo: further tests
do CASE "nodes garbage collection" do CASE "nodes garbage collection"
local wref = weak { create_node(1) } local wref = weak { create_node(1) }
destroy(wref[1])
gc() gc()
CHECK(not wref[1]) CHECK(not wref[1])
end end
do CASE "test"
local x = 1
end
end) end)
TEST("source()", wrap_root(function() TEST("source()", wrap_root(function()
@ -154,31 +283,31 @@ TEST("source()", wrap_root(function()
do CASE "does not update if same value" do CASE "does not update if same value"
local src = source(1) local src = source(1)
local count = -1 local count = 0
watch(function() watch(function()
src() src()
count += 1 count += 1
end) end)
CHECK(count == 0)
src(1)
CHECK(count == 0)
src(2)
CHECK(count == 1) CHECK(count == 1)
src(1)
CHECK(count == 1)
src(2)
CHECK(count == 2)
end end
do CASE "does update if same value is mutable table" do CASE "does update if same value is mutable table"
local src = source {} local src = source {}
local count = -1 local count = 0
watch(function() watch(function()
src() src()
count += 1 count += 1
end) end)
CHECK(count == 0)
src(src())
CHECK(count == 1) CHECK(count == 1)
src(src())
CHECK(count == 2)
end end
do CASE "does not update if same value is frozen table" do CASE "does not update if same value is frozen table"
@ -187,25 +316,26 @@ TEST("source()", wrap_root(function()
local src = source(a) local src = source(a)
local count = -1 local count = 0
watch(function() watch(function()
src() src()
count += 1 count += 1
end) end)
CHECK(count == 0) CHECK(count == 1)
src(a) src(a)
CHECK(count == 0)
src(b)
CHECK(count == 1) CHECK(count == 1)
src(b) src(b)
CHECK(count == 1) CHECK(count == 2)
src(b)
CHECK(count == 2)
end end
end)) end))
TEST("derive()", wrap_root(function() TEST("derive()", wrap_root(function()
local source = vide.source local source = vide.source
local derive = vide.derive local derive = vide.derive
local watch = vide.watch
do CASE "derive new value on source change" do CASE "derive new value on source change"
local a = source(1) local a = source(1)
@ -236,6 +366,30 @@ TEST("derive()", wrap_root(function()
CHECK(c() == 2) CHECK(c() == 2)
end end
do CASE "does not update if same value"
local num = source(0)
local is_even = derive(function()
return num() % 2 == 0
end)
local count = 0
watch(function()
is_even()
count += 1
end)
num(1) -- odd
CHECK(count == 2)
num(2) -- even
CHECK(count == 3)
num(4) -- even
CHECK(count == 3)
num(5) -- odd
CHECK(count == 4)
end
do CASE "garbage collection" do CASE "garbage collection"
-- check that `b` does not allow gc of `a` -- check that `b` does not allow gc of `a`
local a = source(1) local a = source(1)
@ -244,7 +398,7 @@ TEST("derive()", wrap_root(function()
return a() return a()
end) end)
b = nil :: any b = NIL
local wref = weak { a } local wref = weak { a }
@ -256,9 +410,8 @@ end))
TEST("watch()", wrap_root(function() TEST("watch()", wrap_root(function()
local source = vide.source local source = vide.source
local watch = vide.watch local watch = vide.watch
local cleanup = vide.cleanup
do CASE "capture sources" do CASE "rerun on source change"
local a = source(1) local a = source(1)
local b = source(1) local b = source(1)
@ -275,70 +428,31 @@ TEST("watch()", wrap_root(function()
b(2) b(2)
CHECK(count == 3) CHECK(count == 3)
end end
do CASE "side-effect cleanup"
local state = source(1)
local effect_count = 0
local cleanup_count = 0
watch(function()
state()
effect_count += 1
cleanup(function() cleanup_count += 1 end)
end)
CHECK(effect_count == 1)
CHECK(cleanup_count == 0)
state(2)
CHECK(effect_count == 2)
CHECK(cleanup_count == 1)
end
do CASE "garbage collection"
local function factory(p)
return function()
p()
end
end
do -- state prevents gc of watcher
local state = source(1)
local wref
do
local effect = factory(state)
watch(effect)
wref = { effect }
end
gc()
CHECK(wref[1])
end
do -- state can gc with watcher
local wref
do
local state = source(1)
local effect = factory(state)
watch(effect)
wref = weak { state }
end
gc()
CHECK(not wref[1])
end
end
end)) end))
TEST("cleanup()", wrap_root(function() TEST("cleanup()", wrap_root(function()
local root = vide.root
local source = vide.source local source = vide.source
local watch = vide.watch local watch = vide.watch
local cleanup = vide.cleanup local cleanup = vide.cleanup
do CASE "cleanup runs for watcher" do CASE "root cleanup"
local count = 0
local _, destroy = root(function()
cleanup(function()
count += 1
end)
return nil
end)
CHECK(count == 0)
destroy()
CHECK(count == 1)
end
do CASE "cleanup on rerun"
local state = source(1) local state = source(1)
local watched = 0 local watched = 0
@ -381,8 +495,10 @@ TEST("cleanup()", wrap_root(function()
end)) end))
TEST("create()", wrap_root(function() TEST("create()", wrap_root(function()
local root = vide.root
local create = vide.create local create = vide.create
local source = vide.source local source = vide.source
local cleanup = vide.cleanup
do CASE "apply default properties" do CASE "apply default properties"
local defaults = require("src/defaults") local defaults = require("src/defaults")
@ -429,9 +545,7 @@ TEST("create()", wrap_root(function()
{ {
Text = "1", Text = "1",
{ { Text = "2" }
Text = "2"
}
} }
} }
@ -489,129 +603,38 @@ TEST("create()", wrap_root(function()
CHECK(label.Text == "Bar") CHECK(label.Text == "Bar")
end end
do CASE "binding garbage collection" do CASE "binding destroy"
--[[ local count = 0
do -- instance should gc when unparented
local state = source("Hi")
local wref = weak { local _, destroy = root(function()
create "TextLabel" { local src = source(0)
Text = state,
} return create "TextLabel" {
Text = function()
cleanup(function()
count += 1
end)
return src()
end
} }
end)
gc() CHECK(count == 0)
CHECK(not wref[1]) destroy()
end CHECK(count == 1)
]]
--[[
do -- instance should not gc when parented
local state = source("Hi")
local parent = create "Frame" {}
local wref = weak {
create "TextLabel" {
Parent = parent,
Text = state,
}
}
gc()
CHECK(wref[1])
wref[1].Parent = nil
wref[1].Parent = parent
gc()
CHECK(wref[1])
wref[1]:Destroy()
gc()
CHECK(not wref[1])
end
]]
--[[
do -- instance does not allow gc of state
local label
local wref
do
local state = source("Hi")
label = create "TextLabel" {
Name = state,
}
wref = weak { state :: any, label }
end
gc()
CHECK(wref[2])
CHECK(wref[1])
end
]]
do -- state and instance should gc once both exit scope
local wref
do
local text = source("Hi")
local box = create "TextLabel" {
Text = text,
}
wref = weak { text = text, box = box}
end
gc()
CHECK(not wref.text)
CHECK(not wref.box)
end
--[[
do -- binding should gc despite state still existing after instance is gc
local state = source("Hi")
local node = require "src/graph".capture(state)[1]
local wref
do
local instance = create "TextLabel" {
Text = state,
}
local binding = assert(node[1])
wref = weak {
instance = instance,
binding = binding
}
end
CHECK(wref.binding)
gc()
CHECK(not wref.instance)
CHECK(not wref.binding)
end
]]
end end
do CASE "bind same state to multiple instance properties" do CASE "bind same state to multiple instance properties"
local state = source "1" local src = source "1"
local text = create "TextBox" { local text = create "TextBox" {
Name = state, Name = src,
Text = state, Text = src,
PlaceholderText = state PlaceholderText = src
} }
state "2" src "2"
CHECK(text.Name == "2") CHECK(text.Name == "2")
CHECK(text.Text == "2") CHECK(text.Text == "2")
@ -619,7 +642,7 @@ TEST("create()", wrap_root(function()
end end
do CASE "bind children" do CASE "bind children"
local state = source() local children = source()
local a, b, c = local a, b, c =
create "TextLabel" { Name = "A" }, create "TextLabel" { Name = "A" },
@ -627,50 +650,53 @@ TEST("create()", wrap_root(function()
create "TextLabel" { Name = "C" } create "TextLabel" { Name = "C" }
local frame = create "Frame" { local frame = create "Frame" {
state children
} }
state { a, b } children { 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
state { a, c } children { 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")
state(nil) children(nil)
CHECK(#frame:GetChildren() == 0) CHECK(#frame:GetChildren() == 0)
end end
--[[ do CASE "parent bound to source"
do CASE "Parent set to nil by state does not allow gc" local wref, destroy = root(function()
local frame = create "Frame" { Name = "Parent" } local frame = create "Frame" { Name = "Parent" }
local parent = source(frame :: Frame?) local parent = source(frame :: Frame?)
local wref = weak { local wref = weak {
create "TextLabel" { Parent = parent, Name = "Child" } create "TextLabel" { Parent = parent, Name = "Child" }
} }
gc()
CHECK(wref[1])
parent(nil)
return wref
end)
gc() gc()
CHECK(wref[1]) CHECK(wref[1])
parent(nil) destroy()
destroy = NIL
gc()
CHECK(wref[1])
wref[1]:Destroy()
gc() gc()
CHECK(not wref[1]) CHECK(not wref[1])
end end
]]
do CASE "garbage collection test" do CASE "garbage collection test"
local wref local wref
@ -761,7 +787,7 @@ TEST("indexes()", wrap_root(function()
CHECK(t[1].Text == "1") CHECK(t[1].Text == "1")
CHECK(t[2].Text == "2") CHECK(t[2].Text == "2")
CHECK(t[3] == nil :: any) CHECK(t[3] == NIL)
CHECK(destroyed == true) CHECK(destroyed == true)
end end
@ -775,7 +801,7 @@ TEST("indexes()", wrap_root(function()
local wref = weak { input } local wref = weak { input }
input = nil :: any input = NIL
gc() gc()
CHECK(wref[1]) CHECK(wref[1])
@ -790,7 +816,7 @@ TEST("indexes()", wrap_root(function()
local wref = weak { output } local wref = weak { output }
output = nil :: any output = NIL
gc() gc()
CHECK(not wref[1]) CHECK(not wref[1])
@ -880,7 +906,7 @@ TEST("values()", wrap_root(function()
CHECK(t[1].Text == "1") CHECK(t[1].Text == "1")
CHECK(t[2].Text == "2") CHECK(t[2].Text == "2")
CHECK(t[3] == nil :: any) CHECK(t[3] == NIL)
CHECK(destroyed == true) CHECK(destroyed == true)
end end
@ -931,13 +957,14 @@ TEST("spring()", wrap_root(function()
do CASE "update source (on next step)" do CASE "update source (on next step)"
local value = source(10) local value = source(10)
local springed = spring(value, 1, 1) local sprung = spring(value, 1, 1)
CHECK(sprung() == 10)
value(20) value(20)
CHECK(springed() == 10) CHECK(sprung() == 10)
vide.step(1/60) vide.step(1/60)
CHECK(springed() ~= 10) CHECK(sprung() ~= 10)
CHECK(springed() > 10) CHECK(sprung() > 10)
end end
do CASE "garbage collection" do CASE "garbage collection"
@ -947,7 +974,7 @@ TEST("spring()", wrap_root(function()
local _output = spring(input) local _output = spring(input)
local wref = weak { input } local wref = weak { input }
input = nil :: any input = NIL
gc() gc()
CHECK(wref[1]) CHECK(wref[1])
@ -959,28 +986,26 @@ TEST("spring()", wrap_root(function()
local output = spring(input) local output = spring(input)
local wref = weak { output } local wref = weak { output }
output = nil :: any output = NIL
gc() gc()
CHECK(not wref[1]) CHECK(not wref[1])
end end
do -- spring data gc -- do -- spring data gc
local capture = require "src/graph".capture -- local input = source(10)
local input = source(10) -- local wref do
-- local output, data = (spring :: any)(input)
local wref do -- input(input() + 1) -- schedule spring calculation
local output, data = (spring :: any)(input) -- local output_node = unpack(capture(output))
input(input() + 1) -- schedule spring calculation -- wref = weak { output_node, data }
local output_node = unpack(capture(output)) -- end
wref = weak { output_node, data }
end
gc() -- gc()
CHECK(not wref[1]) -- CHECK(not wref[1])
CHECK(not wref[2]) -- CHECK(not wref[2])
end -- end
end end
do CASE "garbage collection (binded)" do CASE "garbage collection (binded)"
@ -992,7 +1017,7 @@ TEST("spring()", wrap_root(function()
} }
local wref = { output } local wref = { output }
output = nil :: any output = NIL
gc() gc()
CHECK(wref[1]) -- `output` should not gc CHECK(wref[1]) -- `output` should not gc
@ -1026,8 +1051,11 @@ TEST("spring()", wrap_root(function()
end)) end))
TEST("untrack()", wrap_root(function() TEST("untrack()", wrap_root(function()
local root = vide.root
local source = vide.source local source = vide.source
local derive = vide.derive
local watch = vide.watch local watch = vide.watch
local cleanup = vide.cleanup
local untrack = vide.untrack local untrack = vide.untrack
do CASE "does not register dependency" do CASE "does not register dependency"
@ -1075,6 +1103,55 @@ TEST("untrack()", wrap_root(function()
b(1) b(1)
CHECK(count == 1) CHECK(count == 1)
end end
do CASE "outer scope"
local outer_count = 0
local inner_count = 0
local cleaned_count = 0
local input = source(0)
local output, destroy = root(function()
local output = derive(function()
outer_count += 1
return untrack(function()
return derive(function()
inner_count += 1
cleanup(function()
cleaned_count += 1
end)
return tostring(input())
end)
end)
end)
return output
end)
CHECK(outer_count == 1)
CHECK(inner_count == 1)
CHECK(cleaned_count == 0)
local output2 = output()
CHECK(output2() == "0")
input(1)
-- todo
CHECK(outer_count == 2)
CHECK(inner_count == 3)
CHECK(cleaned_count == 1)
local output3 = output()
CHECK(output2() == "1")
CHECK(output3() == "1")
CHECK(output2 ~= output3)
end
end)) end))
TEST("events", function() TEST("events", function()