mirror of
https://github.com/centau/vide.git
synced 2026-08-20 14:41:37 +00:00
Adds a built-in `vide.tag()` action that wraps `Instance:AddTag()` / `Instance:RemoveTag()` so tags can be applied through `create()` props. It accepts a static string or array of strings, or a reactive source returning either. The reactive variant diffs the previous and new tag sets so only the delta is applied. All tags added by the action are removed when the surrounding scope is destroyed. Includes mock support for `AddTag`/`RemoveTag`/`HasTag`/`GetTags`, tests, API reference docs, and a closing note in the actions tutorial. Co-authored-by: Cursor <cursoragent@cursor.com>
3282 lines
74 KiB
Text
3282 lines
74 KiB
Text
local testkit = require "./testkit"
|
|
local TEST, CASE, CHECK, FINISH = testkit.test()
|
|
|
|
local mock = require "./mock"
|
|
local Instance, Signal = mock.Instance, mock.Signal
|
|
local Vector2, UDim2 = mock.Vector2, mock.UDim2
|
|
|
|
local vide = require "../../vide"
|
|
local root = vide.root
|
|
local mount = vide.mount
|
|
local create = vide.create
|
|
local source = vide.source
|
|
local effect = vide.effect
|
|
local derive = vide.derive
|
|
local switch = vide.switch
|
|
local show = vide.show
|
|
local indexes = vide.indexes
|
|
local values = vide.values
|
|
local cleanup = vide.cleanup
|
|
local untrack = vide.untrack
|
|
local read = vide.read
|
|
local batch = vide.batch
|
|
local context = vide.context
|
|
local spring = vide.spring
|
|
local action = vide.action
|
|
local changed = vide.changed
|
|
local tag = vide.tag
|
|
local apply = vide.apply
|
|
local step = vide.step
|
|
local graph = require "../../vide/src/graph"
|
|
type Node<T> = graph.Node<T>
|
|
|
|
type Map<K, V> = { [K] : V }
|
|
|
|
local function gc(n: number?)
|
|
for i = 1, n or 3 do
|
|
(collectgarbage :: any)("collect")
|
|
end
|
|
end
|
|
|
|
local function weak<T>(t: T & {}): T
|
|
setmetatable(t :: {}, { __mode = "kv" })
|
|
return t
|
|
end
|
|
|
|
local function wrap_root(fn: () -> ())
|
|
return function()
|
|
local destroy = vide.mount(fn :: any)
|
|
destroy()
|
|
end
|
|
end
|
|
|
|
local NIL = nil :: any
|
|
|
|
vide.strict = false
|
|
|
|
TEST("graph", function()
|
|
local create_node = graph.create_node
|
|
local push_scope_as_child_of = graph.push_scope_as_child_of
|
|
local update_descendants = graph.update_descendants
|
|
local push_child = graph.push_child
|
|
local get_scope = graph.get_scope
|
|
local push_scope = graph.push_scope
|
|
local pop_scope = graph.pop_scope
|
|
local get_children = graph.get_children
|
|
local push_cleanup = graph.push_cleanup
|
|
local destroy = graph.destroy
|
|
|
|
local function node<T>(owner: Node<any>?, v: T?)
|
|
return create_node(owner or false, function(x) return not x end, v or false :: any)
|
|
end
|
|
|
|
local function scope(owner: Node<any>?)
|
|
return create_node(owner or false, false, false)
|
|
end
|
|
|
|
local function cleanup(fn: () -> ())
|
|
local node = assert(get_scope())
|
|
push_cleanup(node, fn)
|
|
end
|
|
|
|
do CASE "link nodes"
|
|
local a = node()
|
|
local b = node()
|
|
local c = node()
|
|
|
|
push_scope(c)
|
|
|
|
push_scope_as_child_of(a)
|
|
push_scope_as_child_of(b)
|
|
|
|
pop_scope()
|
|
|
|
CHECK(get_children(a)[1] == c)
|
|
CHECK(get_children(b)[1] == c)
|
|
end
|
|
|
|
do CASE "rerun linked nodes"
|
|
local root = node()
|
|
local a = node()
|
|
local b = node(root)
|
|
local c = node(root)
|
|
|
|
local count = 0
|
|
|
|
local function effect(x)
|
|
push_scope_as_child_of(a)
|
|
push_scope_as_child_of(b)
|
|
count += 1
|
|
return not x
|
|
end
|
|
|
|
c.effect = effect
|
|
|
|
push_scope(c)
|
|
|
|
effect(c.cache)
|
|
|
|
pop_scope()
|
|
|
|
CHECK(count == 1)
|
|
update_descendants(a)
|
|
CHECK(count == 2)
|
|
update_descendants(b)
|
|
CHECK(count == 3)
|
|
end
|
|
|
|
do CASE "diamond graph"
|
|
-- a -> b -> d
|
|
-- -> c
|
|
local root = node()
|
|
local a, b, c, d = node(), node(root), node(root), node(root)
|
|
|
|
local b_cnt, c_cnt, d_cnt = 0, 0, 0
|
|
function b.effect(x) b_cnt += 1; return not x end
|
|
function c.effect(x) c_cnt += 1; return not x end
|
|
function d.effect(x) d_cnt += 1; return not x end
|
|
|
|
push_scope(b); push_scope_as_child_of(a); pop_scope()
|
|
push_scope(c); push_scope_as_child_of(a); pop_scope()
|
|
push_scope(d); push_scope_as_child_of(b); push_scope_as_child_of(c); pop_scope()
|
|
|
|
update_descendants(a)
|
|
|
|
CHECK(b_cnt == 1)
|
|
CHECK(c_cnt == 1)
|
|
CHECK(d_cnt == 1)
|
|
end
|
|
|
|
do CASE "duplicate child on rerun"
|
|
local root = node()
|
|
local a, b, c = node(root), node(root), node(root)
|
|
|
|
function c.effect(x)
|
|
push_scope_as_child_of(a)
|
|
push_scope_as_child_of(b)
|
|
return not x
|
|
end
|
|
|
|
push_scope(c); assert(type(c.effect) == "function" and c.effect)(NIL); pop_scope()
|
|
|
|
update_descendants(a)
|
|
|
|
CHECK(#get_children(a) == 1)
|
|
CHECK(#get_children(b) == 1)
|
|
end
|
|
|
|
do CASE "case 1"
|
|
-- construct graph
|
|
|
|
local items = node(nil, { "a", "b" })
|
|
local selected = node(nil, "a")
|
|
|
|
local root = scope()
|
|
|
|
local scope1 = scope(root)
|
|
local scope2 = scope(root)
|
|
|
|
local items_updated
|
|
|
|
local bind1
|
|
local bind2
|
|
|
|
local cleaned = {} :: { [any]: any }
|
|
|
|
local function clean(s)
|
|
cleanup(function()
|
|
cleaned[s] = true
|
|
end)
|
|
end
|
|
|
|
do push_scope(root)
|
|
clean "root"
|
|
items_updated = node(root)
|
|
push_scope_as_child_of(items_updated) -- should not
|
|
|
|
do push_scope(items_updated)
|
|
push_scope_as_child_of(items)
|
|
|
|
do push_scope(root)
|
|
do push_scope(scope1)
|
|
clean "scope1"
|
|
bind1 = node(scope1)
|
|
|
|
do push_scope(bind1)
|
|
clean "bind1"
|
|
push_scope_as_child_of(selected)
|
|
pop_scope() end
|
|
pop_scope() end
|
|
|
|
do push_scope(scope2)
|
|
clean "scope2"
|
|
bind2 = node(scope2)
|
|
do push_scope(bind2)
|
|
clean "bind2"
|
|
push_scope_as_child_of(selected)
|
|
pop_scope() end
|
|
pop_scope() end
|
|
pop_scope() end
|
|
pop_scope() end
|
|
pop_scope() end
|
|
|
|
|
|
-- verify graph
|
|
|
|
do
|
|
local c = get_children(items_updated)
|
|
CHECK(#c == 0)
|
|
end
|
|
|
|
do
|
|
local c = get_children(root)
|
|
CHECK(#c == 0)
|
|
-- 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 :: any))
|
|
CHECK(table.find(c, bind2 :: any))
|
|
end
|
|
|
|
do
|
|
local c = get_children(scope1)
|
|
CHECK(#c == 0)
|
|
--CHECK(table.find(c, bind1))
|
|
end
|
|
|
|
do
|
|
local c = get_children(scope2)
|
|
CHECK(#c == 0)
|
|
--CHECK(table.find(c, bind2))
|
|
end
|
|
|
|
-- destroy
|
|
|
|
--CHECK(table.find(get_children(root), scope1 :: Node<any>))
|
|
|
|
destroy(scope1)
|
|
CHECK(cleaned.scope1)
|
|
CHECK(cleaned.bind1)
|
|
scope1 = NIL
|
|
bind1 = NIL
|
|
bind2 = NIL
|
|
gc()
|
|
CHECK(#get_children(root) == 0)
|
|
CHECK(#get_children(selected) == 1)
|
|
end
|
|
|
|
do CASE "nodes garbage collection"
|
|
local wref = weak { node(nil, 1) }
|
|
destroy(wref[1])
|
|
gc()
|
|
CHECK(not wref[1])
|
|
end
|
|
|
|
do CASE "recursive update"
|
|
--[[
|
|
|
|
A -> B + C
|
|
D -> E + F
|
|
|
|
B updates D
|
|
|
|
depth=1
|
|
B, C
|
|
^
|
|
|
|
depth=2
|
|
E, F
|
|
^
|
|
|
|
depth=2
|
|
_, F
|
|
^
|
|
|
|
depth=1
|
|
_, _ <- attempt to update_descendants nothing
|
|
^
|
|
|
|
]]
|
|
|
|
local root = node()
|
|
local a, b, c, d, e, f = node(root), node(root), node(root), node(root), node(root), node(root)
|
|
|
|
function b.effect(x)
|
|
update_descendants(d)
|
|
return not x
|
|
end
|
|
|
|
push_child(a, b); push_child(a, c)
|
|
push_child(d, e); push_child(d, f)
|
|
|
|
update_descendants(a)
|
|
|
|
CHECK(true)
|
|
end
|
|
end)
|
|
|
|
TEST("mount()", function()
|
|
local screen = create "ScreenGui" {}
|
|
|
|
local text = source "foo"
|
|
local count = 0
|
|
|
|
local unmount = mount(function()
|
|
cleanup(function()
|
|
count += 1
|
|
end)
|
|
|
|
return create "TextLabel" {
|
|
Name = "TextLabel",
|
|
Text = text
|
|
}
|
|
end, screen)
|
|
|
|
local label = screen:FindFirstChild "TextLabel" :: TextLabel
|
|
CHECK(label)
|
|
CHECK(label.Text == "foo")
|
|
|
|
text "bar"
|
|
CHECK(label.Text == "bar")
|
|
CHECK(count == 0)
|
|
|
|
unmount()
|
|
CHECK(count == 1)
|
|
end)
|
|
|
|
TEST("root()", function()
|
|
local count = 0
|
|
|
|
root(function(destroy)
|
|
cleanup(function() count += 1 end)
|
|
destroy()
|
|
end)
|
|
|
|
CHECK(count == 1)
|
|
end)
|
|
|
|
TEST("source()", wrap_root(function()
|
|
do CASE "create source"
|
|
local src = source(1)
|
|
CHECK(src() == 1)
|
|
end
|
|
|
|
do CASE "set and get source value"
|
|
local src = source(1)
|
|
src(2)
|
|
CHECK(src() == 2)
|
|
end
|
|
|
|
do CASE "does not update if same value"
|
|
local src = source(1)
|
|
|
|
local count = 0
|
|
effect(function()
|
|
src()
|
|
count += 1
|
|
end)
|
|
|
|
CHECK(count == 1)
|
|
src(1)
|
|
CHECK(count == 1)
|
|
src(2)
|
|
CHECK(count == 2)
|
|
end
|
|
|
|
do CASE "does update if same value is mutable table"
|
|
local src = source {}
|
|
|
|
local count = 0
|
|
effect(function()
|
|
src()
|
|
count += 1
|
|
end)
|
|
|
|
CHECK(count == 1)
|
|
src(src())
|
|
CHECK(count == 2)
|
|
end
|
|
|
|
do CASE "does not update if same value is frozen table"
|
|
local a = table.freeze {}
|
|
local b = table.freeze {}
|
|
|
|
local src = source(a)
|
|
|
|
local count = 0
|
|
effect(function()
|
|
src()
|
|
count += 1
|
|
end)
|
|
|
|
CHECK(count == 1)
|
|
src(a)
|
|
CHECK(count == 1)
|
|
src(b)
|
|
CHECK(count == 2)
|
|
src(b)
|
|
CHECK(count == 2)
|
|
end
|
|
end))
|
|
|
|
TEST("derive()", wrap_root(function()
|
|
do CASE "derive new value on source change"
|
|
local a = source(1)
|
|
local b = source(2)
|
|
|
|
local c = derive(function()
|
|
return tostring(a() + b())
|
|
end)
|
|
|
|
CHECK(c() == "3")
|
|
a(2)
|
|
CHECK(c() == "4")
|
|
end
|
|
|
|
do CASE "derive wrapped source"
|
|
local a = source(1)
|
|
|
|
local b = function()
|
|
return tostring(a())
|
|
end
|
|
|
|
local c = derive(function()
|
|
return tonumber(b())
|
|
end)
|
|
|
|
CHECK(c() == 1)
|
|
a(2)
|
|
CHECK(c() == 2)
|
|
end
|
|
|
|
do CASE "does not update if same value"
|
|
local num = source(0)
|
|
|
|
local is_even = derive(function()
|
|
return bit32.band(num(), 0b01) == 0
|
|
end)
|
|
|
|
local count = 0
|
|
|
|
effect(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 "conditional derive"
|
|
local a = source(false)
|
|
local b = source(false)
|
|
|
|
local c = derive(function()
|
|
return
|
|
if a() then "a"
|
|
elseif b() then "b"
|
|
else "never"
|
|
end)
|
|
|
|
local count = 0
|
|
|
|
effect(function() c(); count += 1 end)
|
|
|
|
b(true)
|
|
CHECK(c() == "b")
|
|
CHECK(count == 2)
|
|
a(true)
|
|
CHECK(c() == "a")
|
|
CHECK(count == 3)
|
|
b(false)
|
|
CHECK(count == 3)
|
|
b(true)
|
|
CHECK(count == 3)
|
|
a(false)
|
|
CHECK(c() == "b")
|
|
CHECK(count == 4)
|
|
end
|
|
|
|
do CASE "owner not disconnected"
|
|
local count = 0
|
|
local a = source(0)
|
|
|
|
local destroy = mount(function()
|
|
local _b = derive(function()
|
|
cleanup(function()
|
|
count += 1
|
|
end)
|
|
|
|
return a()
|
|
end)
|
|
end)
|
|
|
|
CHECK(count == 0)
|
|
a(1) -- b clears parents (should not clear owner)
|
|
CHECK(count == 1)
|
|
destroy()
|
|
CHECK(count == 2)
|
|
end
|
|
|
|
-- do CASE "behavior of effect within an effect"
|
|
-- local num = source(1)
|
|
|
|
-- local ran = table.create(100, 0)
|
|
-- local cleaned = table.create(100, 0)
|
|
|
|
-- local destroy = vide.mount(function()
|
|
-- local owner = derive(function()
|
|
-- local i = num()
|
|
|
|
-- return untrack(function()
|
|
-- return derive(function()
|
|
-- ran[i] += 1
|
|
-- cleanup(function()
|
|
-- cleaned[i] += 1
|
|
-- end)
|
|
-- return i
|
|
-- end)
|
|
-- end)
|
|
-- end)
|
|
|
|
-- local child1 = owner()
|
|
-- num(2)
|
|
-- CHECK(cleaned[1] == 1)
|
|
-- local child2 = owner()
|
|
|
|
-- CHECK(child1() == 1)
|
|
-- CHECK(child2() == 2)
|
|
-- end)
|
|
|
|
-- destroy()
|
|
|
|
-- CHECK(ran[1] == 1)
|
|
-- CHECK(ran[2] == 1)
|
|
-- CHECK(cleaned[1] == 1)
|
|
-- CHECK(cleaned[2] == 1)
|
|
-- end
|
|
|
|
do CASE "garbage collection"
|
|
-- check that `b` does not allow gc of `a`
|
|
local a = source(1)
|
|
|
|
local _b = derive(function()
|
|
return a()
|
|
end)
|
|
|
|
_b = NIL
|
|
|
|
local wref = weak { a }
|
|
|
|
gc()
|
|
CHECK(wref[1])
|
|
end
|
|
end))
|
|
|
|
TEST("effect()", wrap_root(function()
|
|
do CASE "rerun on source change"
|
|
local a = source(1)
|
|
local b = source(1)
|
|
|
|
local count = 0
|
|
effect(function()
|
|
a()
|
|
b()
|
|
count += 1
|
|
end)
|
|
|
|
CHECK(count == 1)
|
|
a(2)
|
|
CHECK(count == 2)
|
|
b(2)
|
|
CHECK(count == 3)
|
|
end
|
|
|
|
do CASE "rerun on derived source change"
|
|
local num = source(0)
|
|
|
|
local text = derive(function() return tostring(num()) end)
|
|
|
|
local count = 0
|
|
effect(function()
|
|
text()
|
|
count += 1
|
|
end)
|
|
|
|
num(1)
|
|
|
|
CHECK(count == 2)
|
|
end
|
|
|
|
do CASE "cache"
|
|
local num = source(0)
|
|
|
|
local count
|
|
|
|
effect(function(x: number)
|
|
num()
|
|
count = x + 1
|
|
return x + 1
|
|
end, 0)
|
|
|
|
num(1)
|
|
|
|
CHECK(count == 2)
|
|
end
|
|
end))
|
|
|
|
TEST("cleanup()", wrap_root(function()
|
|
do CASE "root cleanup"
|
|
local count = 0
|
|
|
|
local destroy = mount(function()
|
|
cleanup(function()
|
|
count += 1
|
|
end)
|
|
end)
|
|
|
|
CHECK(count == 0)
|
|
destroy()
|
|
CHECK(count == 1)
|
|
end
|
|
|
|
do CASE "cleanup on rerun"
|
|
local src = source(1)
|
|
|
|
local effected = 0
|
|
local cleaned = 0
|
|
|
|
effect(function()
|
|
src()
|
|
effected += 1
|
|
cleanup(function()
|
|
cleaned += 1
|
|
end)
|
|
end)
|
|
|
|
CHECK(effected == 1)
|
|
CHECK(cleaned == 0)
|
|
|
|
src(2)
|
|
|
|
CHECK(effected == 2)
|
|
CHECK(cleaned == 1)
|
|
end
|
|
|
|
do CASE "multiple cleanup"
|
|
local src = source(1)
|
|
|
|
local queue = {}
|
|
|
|
effect(function()
|
|
src()
|
|
cleanup(function() table.insert(queue, 1) end)
|
|
cleanup(function() table.insert(queue, 2) end)
|
|
end)
|
|
|
|
CHECK(testkit.seq(queue, {}))
|
|
src(2)
|
|
CHECK(testkit.seq(queue, { 1, 2 }))
|
|
src(3)
|
|
CHECK(testkit.seq(queue, { 1, 2, 1, 2 }))
|
|
end
|
|
|
|
do CASE "cleanup objects"
|
|
local ran = {}
|
|
|
|
root(function(destroy)
|
|
effect(function()
|
|
cleanup { disconnect = function() ran.disconnect = true end }
|
|
cleanup { Disconnect = function() ran.Disconnect = true end }
|
|
cleanup { destroy = function() ran.destroy = true end }
|
|
cleanup { Destroy = function() ran.Destroy = true end }
|
|
destroy()
|
|
end)
|
|
end)
|
|
|
|
CHECK(ran.disconnect)
|
|
CHECK(ran.Disconnect)
|
|
CHECK(ran.destroy)
|
|
CHECK(ran.Destroy)
|
|
end
|
|
end))
|
|
|
|
TEST("create()", wrap_root(function()
|
|
do CASE "create(\"ClassName\", props) syntax"
|
|
local frame = create("Frame", { BackgroundTransparency = 0.5, Name = "Foo" })
|
|
CHECK(frame.BackgroundTransparency == 0.5)
|
|
CHECK(frame.Name == "Foo")
|
|
end
|
|
|
|
do CASE "create(Instance, props) syntax"
|
|
local frame0 = create("Frame", { BackgroundTransparency = 0.5, Name = "Foo" })
|
|
local frame = create(frame0, { BackgroundTransparency = 1 })
|
|
CHECK(frame.BackgroundTransparency == 1)
|
|
CHECK(frame.Name == "Foo")
|
|
end
|
|
|
|
do CASE "apply default properties"
|
|
local defaults = require "../src/defaults"
|
|
local frame = create "Frame" {} :: Instance & { BorderSizePixel: any, BorderColor3: any }
|
|
CHECK(frame.BorderSizePixel == defaults.Frame.BorderSizePixel)
|
|
CHECK(frame.BorderColor3 == defaults.Frame.BorderColor3)
|
|
end
|
|
|
|
do CASE "set properties"
|
|
local text = create "TextLabel" {
|
|
Name = "Label",
|
|
Text = "test"
|
|
}
|
|
CHECK(text.Name == "Label")
|
|
CHECK(text.Text == "test")
|
|
end
|
|
|
|
do CASE "set nested properties"
|
|
local text = create "TextLabel" {
|
|
{ Name = "Label" },
|
|
{ Text = "test" }
|
|
}
|
|
CHECK(text.Name == "Label")
|
|
CHECK(text.Text == "test")
|
|
end
|
|
|
|
do CASE "set nested parent"
|
|
local frame = create "Frame" {}
|
|
local text = create "TextLavel" { { Parent = frame } }
|
|
CHECK(frame:GetChildren()[1] == text)
|
|
CHECK(text.Parent == frame)
|
|
end
|
|
|
|
do CASE "nested deferred"
|
|
local text = create "TextLabel" {
|
|
{
|
|
{ Text = "2" },
|
|
Text = "1",
|
|
}
|
|
}
|
|
|
|
CHECK(text.Text == "2")
|
|
end
|
|
|
|
do CASE "nested not deferred"
|
|
vide.defer_nested_properties = false
|
|
|
|
local t = {}
|
|
|
|
create "TextLabel" {
|
|
{
|
|
{ function() table.insert(t, 1) end } :: any,
|
|
function() table.insert(t, 2) end,
|
|
}
|
|
}
|
|
|
|
CHECK(t[1] == 1)
|
|
CHECK(t[2] == 2)
|
|
|
|
vide.defer_nested_properties = true
|
|
end
|
|
|
|
do CASE "independent"
|
|
local frame = create "Frame"
|
|
CHECK(frame {} ~= frame {})
|
|
end
|
|
|
|
do CASE "set children"
|
|
local frame = create "Frame" {
|
|
create "TextLabel" { Name = "A" },
|
|
create "TextLabel" { Name = "B" },
|
|
{
|
|
create "TextLabel" { Name = "C" } :: any,
|
|
create "TextLabel" { Name = "D" },
|
|
{
|
|
create "TextLabel" { Name = "E" }
|
|
}
|
|
},
|
|
{
|
|
create "TextLabel" { Name = "F" } :: any,
|
|
{ create "TextLabel" { Name = "G" } }
|
|
}
|
|
}
|
|
|
|
CHECK(frame:FindFirstChild "A")
|
|
CHECK(frame:FindFirstChild "B")
|
|
CHECK(frame:FindFirstChild "C")
|
|
CHECK(frame:FindFirstChild "D")
|
|
CHECK(frame:FindFirstChild "E")
|
|
CHECK(frame:FindFirstChild "F")
|
|
CHECK(frame:FindFirstChild "G")
|
|
end
|
|
|
|
do CASE "set false as child"
|
|
create "Frame" {
|
|
false
|
|
}
|
|
|
|
create "Frame" {
|
|
function() return false end
|
|
}
|
|
|
|
create "Frame" {
|
|
function() return { false } end
|
|
}
|
|
end
|
|
|
|
do CASE "binding properties to source"
|
|
local name = source("Hi")
|
|
local text = source("Bye")
|
|
|
|
local label = create "TextLabel" {
|
|
Name = name,
|
|
Text = text
|
|
}
|
|
|
|
CHECK(label.Name == "Hi")
|
|
CHECK(label.Text == "Bye")
|
|
|
|
name "Foo"
|
|
text "Bar"
|
|
|
|
CHECK(label.Name == "Foo")
|
|
CHECK(label.Text == "Bar")
|
|
end
|
|
|
|
do CASE "binding destroy"
|
|
local count = 0
|
|
|
|
local destroy = mount(function()
|
|
local src = source(0)
|
|
|
|
return create "TextLabel" {
|
|
Text = function()
|
|
cleanup(function()
|
|
count += 1
|
|
end)
|
|
|
|
return src()
|
|
end
|
|
}
|
|
end)
|
|
|
|
CHECK(count == 0)
|
|
destroy()
|
|
CHECK(count == 1)
|
|
end
|
|
|
|
do CASE "bind same source to multiple instance properties"
|
|
local src = source "1"
|
|
|
|
local text = create "TextBox" {
|
|
Name = src,
|
|
Text = src,
|
|
PlaceholderText = src
|
|
}
|
|
|
|
src "2"
|
|
|
|
CHECK(text.Name == "2")
|
|
CHECK(text.Text == "2")
|
|
CHECK(text.PlaceholderText == "2")
|
|
end
|
|
|
|
do CASE "bind children"
|
|
local children = source()
|
|
|
|
local a, b, c =
|
|
create "TextLabel" { Name = "A" },
|
|
create "TextLabel" { Name = "B" },
|
|
create "TextLabel" { Name = "C" }
|
|
|
|
local frame = create "Frame" {
|
|
children
|
|
}
|
|
|
|
children { a, b }
|
|
|
|
CHECK(frame:FindFirstChild "A")
|
|
CHECK(frame:FindFirstChild "B")
|
|
|
|
-- check that b is removed and c is added while a remains untouched
|
|
|
|
children { a, c }
|
|
|
|
CHECK(frame:FindFirstChild "A")
|
|
CHECK(frame:FindFirstChild "C")
|
|
CHECK(not frame:FindFirstChild "B")
|
|
|
|
children(nil)
|
|
|
|
CHECK(#frame:GetChildren() == 0)
|
|
end
|
|
|
|
do CASE "parent bound to source"
|
|
local _, wref, destroy = root(function(destroy)
|
|
local frame = create "Frame" { Name = "Parent" }
|
|
local parent = source(frame :: Frame?)
|
|
|
|
local wref = weak {
|
|
create "TextLabel" { Parent = parent, Name = "Child" }
|
|
}
|
|
|
|
gc()
|
|
CHECK(wref[1])
|
|
|
|
parent(nil)
|
|
|
|
return wref, destroy
|
|
end)
|
|
|
|
gc()
|
|
CHECK(wref[1])
|
|
|
|
destroy()
|
|
destroy = NIL
|
|
|
|
gc()
|
|
CHECK(not wref[1])
|
|
end
|
|
|
|
do CASE "recursive create"
|
|
local set_test_to_true = action(function(self) (self :: any).test = true end)
|
|
|
|
local f2
|
|
|
|
local to_apply = {
|
|
{ a = 1 },
|
|
set_test_to_true,
|
|
b = function() f2 = create "Frame" { a = 2 } end,
|
|
} :: { [number|string]: unknown }
|
|
|
|
-- do -- confirm iteration order
|
|
-- local t = {}
|
|
-- for i in to_apply do
|
|
-- table.insert(t, i)
|
|
-- end
|
|
-- assert(t[1] == "a")
|
|
-- end
|
|
|
|
local f = create "Frame" (to_apply)
|
|
|
|
CHECK((f :: any).a == 1)
|
|
CHECK((f :: any).test == true )
|
|
|
|
CHECK((f2 :: any).a == 2)
|
|
end
|
|
|
|
do CASE "nested children effect"
|
|
local a = create "Frame" { Name = "a" }
|
|
local b = create "Frame" { Name = "b" }
|
|
local c = create "Frame" { Name = "c" }
|
|
local d = create "Frame" { Name = "d" }
|
|
local e = create "Frame" { Name = "e" }
|
|
|
|
local children = source {
|
|
a,
|
|
{ b, c, { d } },
|
|
{ { e } }
|
|
}
|
|
|
|
local obj = create "Frame" {
|
|
children
|
|
}
|
|
|
|
CHECK(obj:FindFirstChild("a"))
|
|
CHECK(obj:FindFirstChild("b"))
|
|
CHECK(obj:FindFirstChild("c"))
|
|
CHECK(obj:FindFirstChild("d"))
|
|
CHECK(obj:FindFirstChild("e"))
|
|
|
|
children {
|
|
b,
|
|
{ c, a },
|
|
{ { d } }
|
|
}
|
|
|
|
CHECK(obj:FindFirstChild("a"))
|
|
CHECK(obj:FindFirstChild("b"))
|
|
CHECK(obj:FindFirstChild("c"))
|
|
CHECK(obj:FindFirstChild("d"))
|
|
CHECK(not obj:FindFirstChild("e"))
|
|
end
|
|
|
|
do CASE "nested children source effect"
|
|
local a = create "Frame" { Name = "a" } :: Instance
|
|
local b = create "Frame" { Name = "b" } :: Instance
|
|
local c = create "Frame" { Name = "c" } :: Instance
|
|
|
|
local nested_children = source { b, c }
|
|
local children = source { a :: Instance | () -> { Instance }, nested_children }
|
|
|
|
local parent = create "Frame" {
|
|
Name = "parent",
|
|
children
|
|
}
|
|
|
|
CHECK(parent:FindFirstChild "a")
|
|
CHECK(parent:FindFirstChild "b")
|
|
CHECK(parent:FindFirstChild "c")
|
|
nested_children {}
|
|
CHECK(parent:FindFirstChild "a")
|
|
CHECK(not parent:FindFirstChild "b")
|
|
CHECK(not parent:FindFirstChild "c")
|
|
nested_children { b }
|
|
CHECK(parent:FindFirstChild "a")
|
|
CHECK(parent:FindFirstChild "b")
|
|
CHECK(not parent:FindFirstChild "c")
|
|
children { a }
|
|
CHECK(parent:FindFirstChild "a")
|
|
CHECK(not parent:FindFirstChild "b")
|
|
CHECK(not parent:FindFirstChild "c")
|
|
nested_children { b, c }
|
|
CHECK(parent:FindFirstChild "a")
|
|
CHECK(not parent:FindFirstChild "b")
|
|
CHECK(not parent:FindFirstChild "c")
|
|
children { a :: Instance | () -> { Instance }, nested_children }
|
|
CHECK(parent:FindFirstChild "a")
|
|
CHECK(parent:FindFirstChild "b")
|
|
CHECK(parent:FindFirstChild "c")
|
|
nested_children { c }
|
|
CHECK(parent:FindFirstChild "a")
|
|
CHECK(not parent:FindFirstChild "b")
|
|
CHECK(parent:FindFirstChild "c")
|
|
end
|
|
|
|
do CASE "garbage collection test"
|
|
local wref
|
|
|
|
do
|
|
local data = setmetatable({}, {})
|
|
local proxy = setmetatable({}, { __mode = "v" })
|
|
|
|
local ref = setmetatable({}, { __mode = "v" })
|
|
|
|
--proxy.data = data --? (this line should not affect outcome)
|
|
-- `data` strongly references `proxy`
|
|
data.connection = proxy
|
|
|
|
-- `ref` strongly references `data`
|
|
ref[data] = proxy
|
|
|
|
-- although `ref` is weak to values, `data` keeps `proxy` alive
|
|
-- this forms a sort of cyclic reference that the luau gc is unable to detect
|
|
|
|
wref = { data = data, proxy = proxy }
|
|
end
|
|
|
|
gc()
|
|
CHECK(wref.data and wref.proxy)
|
|
end
|
|
end))
|
|
|
|
TEST("show()", wrap_root(function()
|
|
do CASE "show component"
|
|
local input = source(true)
|
|
local function one() return 1 end
|
|
|
|
local output = show(input, one)
|
|
|
|
CHECK(output() == 1)
|
|
input(false)
|
|
CHECK(output() == nil)
|
|
end
|
|
|
|
do CASE "fallback component"
|
|
local input = source(true)
|
|
local function one() return 1 end
|
|
local function two() return 2 end
|
|
|
|
local output = show(input, one, two)
|
|
|
|
CHECK(output() == 1)
|
|
input(false)
|
|
CHECK(output() == 2)
|
|
end
|
|
|
|
do CASE "updating truth to truthy does not rerun"
|
|
local input = source(1)
|
|
local count = 0
|
|
|
|
local function component()
|
|
count += 1
|
|
return 1
|
|
end
|
|
|
|
local output = show(input, component)
|
|
|
|
CHECK(count == 1)
|
|
CHECK(output() == 1)
|
|
input(2)
|
|
CHECK(count == 1)
|
|
end
|
|
|
|
do CASE "updating source passed to component"
|
|
local input = source(1 :: number?)
|
|
local count = 0
|
|
|
|
show(input :: () -> number?, function(value: () -> number)
|
|
effect(function()
|
|
local v = value()
|
|
|
|
count += 1
|
|
|
|
CHECK(v == count)
|
|
if v ~= count then error(count) end
|
|
end)
|
|
|
|
return true
|
|
end)
|
|
|
|
input(2)
|
|
CHECK(count == 2)
|
|
input(3)
|
|
CHECK(count == 3)
|
|
input(nil)
|
|
CHECK(count == 3)
|
|
end
|
|
|
|
do CASE "special strict case"
|
|
type Weapon = {
|
|
id: string,
|
|
enchant: string?
|
|
}
|
|
|
|
vide.strict = true
|
|
|
|
local count = 0
|
|
local branch = 0
|
|
|
|
local weapon = source(nil :: Weapon?)
|
|
|
|
effect(function()
|
|
weapon()
|
|
end)
|
|
|
|
effect(function()
|
|
weapon()
|
|
end)
|
|
|
|
show(weapon, function(weapon: () -> Weapon)
|
|
local enchant = function() return weapon().enchant end
|
|
|
|
show(enchant, function(enchant: () -> string)
|
|
effect(function()
|
|
local e = enchant()
|
|
count += 1
|
|
CHECK(e ~= nil)
|
|
if branch == 1 then
|
|
CHECK(e == "fire")
|
|
elseif branch == 2 then
|
|
CHECK(e == "poison")
|
|
end
|
|
end)
|
|
|
|
return {}
|
|
end)
|
|
|
|
return {}
|
|
end)
|
|
|
|
effect(function()
|
|
weapon()
|
|
end)
|
|
|
|
effect(function()
|
|
weapon()
|
|
end)
|
|
|
|
branch = 1
|
|
weapon { id = "1", enchant = "fire" }
|
|
CHECK(count == 2)
|
|
|
|
branch = 2
|
|
weapon { id = "1", enchant = "poison" }
|
|
CHECK(count == 4)
|
|
|
|
weapon { id = "1", enchant = nil }
|
|
CHECK(count == 4)
|
|
|
|
branch = 1
|
|
weapon { id = "1", enchant = "fire" }
|
|
CHECK(count == 6)
|
|
|
|
weapon(nil)
|
|
|
|
branch = 2
|
|
weapon { id = "1", enchant = "poison" }
|
|
CHECK(count == 8)
|
|
|
|
vide.strict = false
|
|
end
|
|
|
|
do CASE "alt" -- todo: move test
|
|
local visible = source(true)
|
|
local count = source(0)
|
|
|
|
local outer = 0
|
|
local inner = 0
|
|
local destroyed = 0
|
|
|
|
root(function()
|
|
effect(function()
|
|
visible()
|
|
outer += 1
|
|
|
|
untrack(function()
|
|
effect(function()
|
|
count()
|
|
|
|
inner += 1
|
|
|
|
cleanup(function()
|
|
destroyed += 1
|
|
end)
|
|
end)
|
|
end)
|
|
end)
|
|
end)
|
|
|
|
CHECK(outer == 1)
|
|
CHECK(inner == 1)
|
|
CHECK(destroyed == 0)
|
|
|
|
count(count() + 1)
|
|
CHECK(outer == 1)
|
|
CHECK(inner == 2)
|
|
CHECK(destroyed == 1)
|
|
|
|
visible(false)
|
|
CHECK(outer == 2)
|
|
CHECK(inner == 3)
|
|
CHECK(destroyed == 2)
|
|
|
|
count(count() + 1)
|
|
CHECK(outer == 2)
|
|
CHECK(inner == 4)
|
|
CHECK(destroyed == 3)
|
|
end
|
|
|
|
do CASE "delay (destruction)"
|
|
local input = source(false)
|
|
|
|
local obj = {}
|
|
local value_upval
|
|
local present_upval
|
|
local cleaned = false
|
|
|
|
local output = show(input, function(value, present)
|
|
value_upval = value
|
|
present_upval = present
|
|
cleanup(function() cleaned = true end)
|
|
CHECK(present() == false)
|
|
return obj, 1
|
|
end)
|
|
|
|
CHECK(output() == nil)
|
|
|
|
input(true)
|
|
|
|
CHECK(output() == obj)
|
|
CHECK(value_upval() == true)
|
|
CHECK(present_upval() == true)
|
|
CHECK(not cleaned)
|
|
|
|
input(false)
|
|
|
|
CHECK(output() == obj)
|
|
CHECK(value_upval() == true)
|
|
CHECK(present_upval() == false)
|
|
CHECK(not cleaned)
|
|
|
|
step(0.5)
|
|
|
|
CHECK(output() == obj)
|
|
CHECK(value_upval() == true)
|
|
CHECK(present_upval() == false)
|
|
CHECK(not cleaned)
|
|
|
|
step(0.5 + 0.01)
|
|
|
|
CHECK(output() == nil)
|
|
CHECK(value_upval() == true)
|
|
CHECK(present_upval() == false)
|
|
CHECK(cleaned)
|
|
end
|
|
|
|
do CASE "delay (reactivate before destruction)"
|
|
local input = source(false)
|
|
|
|
local obj = {}
|
|
local value_upval
|
|
local present_upval
|
|
local cleaned = false
|
|
|
|
local output = show(input, function(value, present)
|
|
value_upval = value
|
|
present_upval = present
|
|
cleanup(function() cleaned = true end)
|
|
CHECK(present() == false)
|
|
return obj, 1
|
|
end)
|
|
|
|
CHECK(output() == nil)
|
|
|
|
input(true)
|
|
|
|
CHECK(output() == obj)
|
|
CHECK(value_upval() == true)
|
|
CHECK(present_upval() == true)
|
|
CHECK(not cleaned)
|
|
|
|
input(false)
|
|
|
|
CHECK(output() == obj)
|
|
CHECK(value_upval() == true)
|
|
CHECK(present_upval() == false)
|
|
CHECK(not cleaned)
|
|
|
|
step(0.5)
|
|
|
|
CHECK(output() == obj)
|
|
CHECK(value_upval() == true)
|
|
CHECK(present_upval() == false)
|
|
CHECK(not cleaned)
|
|
|
|
input(true)
|
|
|
|
CHECK(output() == obj)
|
|
CHECK(value_upval() == true)
|
|
CHECK(present_upval() == true)
|
|
CHECK(not cleaned)
|
|
|
|
step(0.5 + 0.01)
|
|
|
|
CHECK(output() == obj)
|
|
CHECK(value_upval() == true)
|
|
CHECK(present_upval() == true)
|
|
CHECK(not cleaned)
|
|
end
|
|
|
|
do CASE "delay (with fallback)"
|
|
local input = source(false)
|
|
|
|
local obj = {}
|
|
local value_upval
|
|
local present_upval
|
|
local cleaned = false
|
|
|
|
local obj_fallback = {}
|
|
local present_fallback_upval
|
|
local cleaned_fallback = false
|
|
|
|
local output = show(input, function(value, present)
|
|
value_upval = value
|
|
present_upval = present
|
|
cleanup(function() cleaned = true end)
|
|
CHECK(present() == false)
|
|
return obj, 1
|
|
end, function(present)
|
|
present_fallback_upval = present
|
|
cleanup(function() cleaned_fallback = true end)
|
|
CHECK(present() == false)
|
|
return obj_fallback, 1
|
|
end)
|
|
|
|
CHECK(output() == obj_fallback)
|
|
CHECK(value_upval == nil)
|
|
CHECK(present_upval == nil)
|
|
CHECK(not cleaned)
|
|
CHECK(present_fallback_upval() == true)
|
|
CHECK(not cleaned_fallback)
|
|
|
|
input(true)
|
|
|
|
CHECK(type(output() == "table") and table.find(output(), obj) and table.find(output(), obj_fallback))
|
|
CHECK(value_upval() == true)
|
|
CHECK(present_upval() == true)
|
|
CHECK(not cleaned)
|
|
CHECK(present_fallback_upval() == false)
|
|
CHECK(not cleaned_fallback)
|
|
|
|
step(1 + 0.01)
|
|
|
|
CHECK(output() == obj)
|
|
CHECK(value_upval() == true)
|
|
CHECK(present_upval() == true)
|
|
CHECK(not cleaned)
|
|
CHECK(present_fallback_upval() == false)
|
|
CHECK(cleaned_fallback)
|
|
end
|
|
end))
|
|
|
|
TEST("switch()", wrap_root(function()
|
|
do CASE "update on source change"
|
|
local input = source(true)
|
|
|
|
local output = switch(input) {
|
|
[true] = function() return 1 end,
|
|
[false] = function() return 0 end
|
|
}
|
|
|
|
local count = 0
|
|
|
|
effect(function() output(); count += 1 end)
|
|
|
|
CHECK(count == 1)
|
|
CHECK(output() == 1)
|
|
|
|
input(false)
|
|
CHECK(output() == 0)
|
|
CHECK(count == 2)
|
|
|
|
input(false)
|
|
CHECK(output() == 0)
|
|
CHECK(count == 2)
|
|
|
|
input(NIL)
|
|
CHECK(output() == nil)
|
|
end
|
|
|
|
-- do CASE "same component different map"
|
|
-- local input = source(0)
|
|
|
|
-- local function component()
|
|
-- return {}
|
|
-- end
|
|
|
|
-- local output = switch(input) {
|
|
-- [1] = component,
|
|
-- [2] = component
|
|
-- }
|
|
|
|
-- CHECK(output() == nil)
|
|
|
|
-- input(1)
|
|
-- local instance = output()
|
|
-- CHECK(instance)
|
|
|
|
-- input(2)
|
|
-- CHECK(output() == instance)
|
|
-- end
|
|
|
|
do CASE "scoped switch"
|
|
local input = source(true)
|
|
|
|
local owner_count = 0
|
|
local switch0_count = 0
|
|
local switch1_count = 0
|
|
|
|
cleanup(function() owner_count += 1 end)
|
|
|
|
local output = switch(input) {
|
|
[true] = function()
|
|
cleanup(function() switch1_count += 1 end)
|
|
return 1
|
|
end,
|
|
|
|
[false] = function()
|
|
cleanup(function() switch0_count += 1 end)
|
|
return 0
|
|
end
|
|
}
|
|
|
|
CHECK(output() == 1)
|
|
input(false)
|
|
CHECK(switch1_count == 1)
|
|
CHECK(switch0_count == 0)
|
|
input(true)
|
|
CHECK(switch1_count == 1)
|
|
CHECK(switch0_count == 1)
|
|
input(NIL)
|
|
CHECK(switch1_count == 2)
|
|
CHECK(switch0_count == 1)
|
|
CHECK(owner_count == 0)
|
|
end
|
|
|
|
do CASE "reactive stack resets after error"
|
|
local scopes = require "../src/graph".scopes
|
|
local input = source(1)
|
|
|
|
local n0 = scopes.n
|
|
|
|
local ok = pcall(function()
|
|
switch(input) {
|
|
error :: any
|
|
}
|
|
end)
|
|
|
|
CHECK(not ok)
|
|
|
|
local n1 = scopes.n
|
|
|
|
CHECK(n0 == n1)
|
|
end
|
|
|
|
do CASE "strict"
|
|
vide.strict = true
|
|
|
|
local input = source(0)
|
|
local output = switch(input) {
|
|
[0] = function() return 0 end,
|
|
[1] = function() return 1 end,
|
|
}
|
|
|
|
CHECK(output() == 0)
|
|
input(1)
|
|
CHECK(output() == 1)
|
|
|
|
vide.strict = false
|
|
end
|
|
|
|
do CASE "delay"
|
|
-- probably unneeded because show() uses switch() internally
|
|
end
|
|
end))
|
|
|
|
TEST("indexes()", wrap_root(function()
|
|
do CASE "use source"
|
|
local input = source { 1, 2, 3 }
|
|
|
|
local output = indexes(input, function(v, k)
|
|
return tostring(v())
|
|
end)
|
|
|
|
CHECK("" .. input()[1] == output()[1])
|
|
CHECK("" .. input()[2] == output()[2])
|
|
CHECK("" .. input()[3] == output()[3])
|
|
end
|
|
|
|
do CASE "cache result"
|
|
local input = source { 1, 2, 3 }
|
|
|
|
local count = table.create(3, 0)
|
|
|
|
local _, output = root(function()
|
|
local output = indexes(input, function(v, i)
|
|
count[i] += 1
|
|
return v
|
|
end)
|
|
|
|
return output
|
|
end)
|
|
|
|
input { 1, 2, 4 }
|
|
|
|
CHECK(output()[1]() == 1)
|
|
CHECK(output()[2]() == 2)
|
|
CHECK(output()[3]() == 4)
|
|
|
|
CHECK(count[1] == 1)
|
|
CHECK(count[2] == 1)
|
|
CHECK(count[3] == 1)
|
|
end
|
|
|
|
do CASE "removal reflected"
|
|
local input = source { 1, 2, 3 }
|
|
|
|
local destroyed = false
|
|
|
|
local output = indexes(input, function(v, i)
|
|
local text = create "TextLabel" {
|
|
Text = function() return tostring(v()) end
|
|
}
|
|
|
|
cleanup(function()
|
|
destroyed = true
|
|
end)
|
|
|
|
return text
|
|
end)
|
|
|
|
input { 1, 2 }
|
|
|
|
local t = output()
|
|
|
|
CHECK(t[1].Text == "1")
|
|
CHECK(t[2].Text == "2")
|
|
CHECK(t[3] == NIL)
|
|
CHECK(destroyed == true)
|
|
end
|
|
|
|
do CASE "garbage collection"
|
|
do -- check that `output` does not allow gc of `input`
|
|
local input = source {}
|
|
|
|
local _derived = indexes(input, function(v, i)
|
|
return v
|
|
end)
|
|
|
|
local wref = weak { input }
|
|
|
|
input = NIL
|
|
|
|
gc()
|
|
CHECK(wref[1])
|
|
end
|
|
|
|
do -- check that `input` allows gc of `output`
|
|
local input = source {}
|
|
|
|
local destroy, output = root(function()
|
|
return indexes(input, function(v, i)
|
|
return v, i
|
|
end)
|
|
end)
|
|
destroy()
|
|
|
|
local wref = weak { output }
|
|
|
|
output = NIL
|
|
|
|
gc()
|
|
CHECK(not wref[1])
|
|
end
|
|
end
|
|
|
|
do CASE "cleanup"
|
|
local input = source { 1, 2, 3 }
|
|
|
|
local count = table.create(3, 0)
|
|
|
|
local output = indexes(input, function(v, i)
|
|
cleanup(function()
|
|
count[i] += 1
|
|
end)
|
|
|
|
return {}
|
|
end)
|
|
|
|
output()
|
|
|
|
CHECK(count[1] == 0)
|
|
CHECK(count[2] == 0)
|
|
CHECK(count[3] == 0)
|
|
end
|
|
|
|
do CASE "reactive stack resets after error"
|
|
local scopes = require "../src/graph".scopes
|
|
|
|
local input = source { 1 }
|
|
|
|
local n0 = scopes.n
|
|
|
|
local ok = pcall(function()
|
|
indexes(input, function()
|
|
error("")
|
|
return NIL
|
|
end)
|
|
end)
|
|
|
|
CHECK(not ok)
|
|
|
|
local n1 = scopes.n
|
|
|
|
CHECK(n0 == n1)
|
|
end
|
|
|
|
-- practical example based on the graph - recursive update test
|
|
do CASE "recursive update"
|
|
local items = source { 1 }
|
|
|
|
local updated = table.create(100, 0)
|
|
|
|
indexes(items, function(item)
|
|
effect(function()
|
|
item()
|
|
updated[1] += 1
|
|
end)
|
|
|
|
effect(function()
|
|
item()
|
|
updated[2] += 1
|
|
end)
|
|
|
|
return {}
|
|
end)
|
|
|
|
effect(function()
|
|
items()
|
|
updated[3] += 1
|
|
end)
|
|
|
|
effect(function()
|
|
items()
|
|
updated[4] += 1
|
|
end)
|
|
|
|
items { 2 }
|
|
|
|
CHECK(updated[1] == 2)
|
|
CHECK(updated[2] == 2)
|
|
CHECK(updated[3] == 2)
|
|
CHECK(updated[4] == 2)
|
|
end
|
|
|
|
do CASE "strict"
|
|
vide.strict = true
|
|
|
|
local input = source{1}
|
|
local output = indexes(input, function(v)
|
|
return { v }
|
|
end)
|
|
|
|
CHECK(output()[1][1]() == 1)
|
|
input{2}
|
|
CHECK(output()[1][1]() == 2)
|
|
|
|
vide.strict = false
|
|
end
|
|
|
|
do CASE "delay"
|
|
local input = source {}
|
|
|
|
local cleaned_counts = {} :: Map<number, number>
|
|
|
|
local output = indexes(input, function(v, i, present)
|
|
cleanup(function()
|
|
cleaned_counts[i] = (cleaned_counts[i] or 0) + 1
|
|
end)
|
|
return { value = v, index = i, present = present }, 1
|
|
end)
|
|
|
|
local function mapped()
|
|
local map = {}
|
|
local objects = output()
|
|
if objects then
|
|
for _, object in objects do
|
|
map[object.index] = { value = object.value, present = object.present }
|
|
end
|
|
end
|
|
return map
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
|
|
do
|
|
CHECK(mapped()[1] == nil)
|
|
end
|
|
|
|
input { 1, 2 }
|
|
|
|
do
|
|
CHECK(mapped()[1].value() == 1)
|
|
CHECK(mapped()[1].present())
|
|
|
|
CHECK(mapped()[2].value() == 2)
|
|
CHECK(mapped()[2].present())
|
|
end
|
|
|
|
input { 2 }
|
|
step(0.5)
|
|
|
|
do
|
|
CHECK(mapped()[1].value() == 2)
|
|
CHECK(mapped()[1].present())
|
|
|
|
CHECK(mapped()[2].value() == 2)
|
|
CHECK(not mapped()[2].present())
|
|
end
|
|
|
|
input { 1, 2 }
|
|
step(0.5 + 0.01)
|
|
|
|
do
|
|
CHECK(mapped()[1].value() == 1)
|
|
CHECK(mapped()[1].present())
|
|
|
|
CHECK(mapped()[2].value() == 2)
|
|
CHECK(mapped()[2].present())
|
|
end
|
|
|
|
input { 3 }
|
|
step(1 + 0.01)
|
|
|
|
do
|
|
CHECK(mapped()[1].value() == 3)
|
|
CHECK(mapped()[1].present())
|
|
|
|
CHECK(not mapped()[2])
|
|
|
|
CHECK(cleaned_counts[1] == nil)
|
|
CHECK(cleaned_counts[2] == 1)
|
|
end
|
|
end
|
|
end))
|
|
|
|
TEST("values()", wrap_root(function()
|
|
do CASE "use source"
|
|
local input = source { 1, 2, 3 }
|
|
|
|
local output = values(input, function(v, k)
|
|
return tostring(v)
|
|
end)
|
|
|
|
CHECK("" .. input()[1] == output()[1])
|
|
CHECK("" .. input()[2] == output()[2])
|
|
CHECK("" .. input()[3] == output()[3])
|
|
end
|
|
|
|
do CASE "cache result"
|
|
local input = source { 1, 2, 3 }
|
|
|
|
local count = table.create(3, 0)
|
|
|
|
local output = values(input, function(v, i)
|
|
count[v] += 1
|
|
return i
|
|
end)
|
|
|
|
input { 1, 3, 2 }
|
|
|
|
CHECK(output()[1]() == 1)
|
|
CHECK(output()[2]() == 3)
|
|
CHECK(output()[3]() == 2)
|
|
|
|
CHECK(count[1] == 1)
|
|
CHECK(count[2] == 1)
|
|
CHECK(count[3] == 1)
|
|
end
|
|
|
|
do CASE "removal reflected"
|
|
local input = source { 1, 2, 3 }
|
|
|
|
local destroyed = false
|
|
|
|
local output = values(input, function(v, i)
|
|
local text = create "TextLabel" {
|
|
Text = tostring(v)
|
|
}
|
|
|
|
cleanup(function()
|
|
destroyed = true
|
|
end)
|
|
|
|
return text
|
|
end)
|
|
|
|
input { 1, 2 }
|
|
|
|
local t = output()
|
|
|
|
CHECK(t[1].Text == "1")
|
|
CHECK(t[2].Text == "2")
|
|
CHECK(t[3] == NIL)
|
|
CHECK(destroyed == true)
|
|
end
|
|
|
|
do CASE "removal reflected 2"
|
|
local input = source { 1 }
|
|
|
|
local output = values(input, function(v, i)
|
|
return { v = v, i = i }
|
|
end)
|
|
|
|
input { 2, 1 }
|
|
input { 1 }
|
|
|
|
local t = output()
|
|
|
|
CHECK(t[1].v == 1)
|
|
CHECK(t[1].i() == 1)
|
|
CHECK(t[2] == nil)
|
|
CHECK(t[3] == nil)
|
|
end
|
|
|
|
do CASE "cleanup"
|
|
local input = source { 1, 2, 3 }
|
|
|
|
local count = table.create(3, 0)
|
|
|
|
local output = values(input, function(v, i)
|
|
cleanup(function()
|
|
count[i()] += 1
|
|
end)
|
|
|
|
return {}
|
|
end)
|
|
|
|
output()
|
|
|
|
CHECK(count[1] == 0)
|
|
CHECK(count[2] == 0)
|
|
CHECK(count[3] == 0)
|
|
end
|
|
|
|
do CASE "reactive stack resets after error"
|
|
local scopes = require "../src/graph".scopes
|
|
|
|
local input = source { 1 }
|
|
|
|
local n0 = scopes.n
|
|
|
|
local ok = pcall(function()
|
|
values(input, function()
|
|
error("")
|
|
return NIL
|
|
end)
|
|
end)
|
|
|
|
CHECK(not ok)
|
|
|
|
local n1 = scopes.n
|
|
|
|
CHECK(n0 == n1)
|
|
end
|
|
|
|
do CASE "delay"
|
|
local input = source {}
|
|
|
|
local cleaned_counts = {} :: Map<number, number>
|
|
|
|
local output = values(input, function(v, i, present)
|
|
cleanup(function()
|
|
cleaned_counts[v] = (cleaned_counts[v] or 0) + 1
|
|
end)
|
|
return { value = v, index = i, present = present }, 1
|
|
end)
|
|
|
|
local function mapped()
|
|
local map = {}
|
|
local objects = output()
|
|
if objects then
|
|
for _, object in objects do
|
|
map[object.value] = { index = object.index, present = object.present }
|
|
end
|
|
end
|
|
return map
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
|
|
do
|
|
CHECK(mapped()[1] == nil)
|
|
end
|
|
|
|
input { 1 }
|
|
|
|
do
|
|
CHECK(mapped()[1].index() == 1)
|
|
CHECK(mapped()[1].present())
|
|
end
|
|
|
|
input { 2 }
|
|
step(0.5)
|
|
|
|
do
|
|
CHECK(mapped()[1].index() == 1)
|
|
CHECK(not mapped()[1].present())
|
|
|
|
CHECK(mapped()[2].index() == 1)
|
|
CHECK(mapped()[2].present())
|
|
end
|
|
|
|
input { 1, 2 }
|
|
step(0.5 + 0.01)
|
|
|
|
do
|
|
CHECK(mapped()[1].index() == 1)
|
|
CHECK(mapped()[1].present())
|
|
|
|
CHECK(mapped()[2].index() == 2)
|
|
CHECK(mapped()[2].present())
|
|
end
|
|
|
|
input { 3 }
|
|
step(1 + 0.01)
|
|
|
|
do
|
|
CHECK(not mapped()[1])
|
|
CHECK(not mapped()[2])
|
|
|
|
CHECK(mapped()[3].index() == 1)
|
|
CHECK(mapped()[3].present())
|
|
|
|
CHECK(cleaned_counts[1] == 1)
|
|
CHECK(cleaned_counts[2] == 1)
|
|
CHECK(cleaned_counts[3] == nil)
|
|
end
|
|
end
|
|
|
|
do CASE "delayed destruction deferred"
|
|
local input = source {}
|
|
local output = values(input, function()
|
|
return {}, 1
|
|
end)
|
|
|
|
local count = 0
|
|
effect(function() output(); count += 1 end)
|
|
|
|
input { 1, 2, 3 }
|
|
CHECK(count == 2)
|
|
|
|
input {}
|
|
CHECK(count == 2)
|
|
|
|
step(1 + 0.01)
|
|
--CHECK(count == 3)
|
|
CHECK(count == 5)
|
|
end
|
|
end))
|
|
|
|
TEST("spring()", wrap_root(function()
|
|
do CASE "update source (on next step)"
|
|
local value = source(10)
|
|
local sprung = spring(value, 1, 1)
|
|
|
|
CHECK(sprung() == 10)
|
|
value(20)
|
|
CHECK(sprung() == 10)
|
|
step(1/60)
|
|
CHECK(sprung() ~= 10)
|
|
CHECK(sprung() > 10)
|
|
end
|
|
|
|
do CASE "garbage collection"
|
|
--[[
|
|
do -- `output` should not allow gc of `input`
|
|
local input = source(10)
|
|
local _output = spring(input)
|
|
|
|
local wref = weak { input }
|
|
input = NIL
|
|
|
|
gc()
|
|
CHECK(wref[1])
|
|
end
|
|
]]
|
|
|
|
do -- `input` should allow gc of `output`
|
|
local input = source(10)
|
|
local output = spring(input)
|
|
|
|
local wref = weak { output }
|
|
output = NIL
|
|
|
|
gc()
|
|
CHECK(not wref[1])
|
|
end
|
|
|
|
-- do -- spring data gc
|
|
-- local input = source(10)
|
|
|
|
-- local wref do
|
|
-- local output, data = (spring :: any)(input)
|
|
-- input(input() + 1) -- schedule spring calculation
|
|
-- local output_node = unpack(capture(output))
|
|
-- wref = weak { output_node, data }
|
|
-- end
|
|
|
|
-- gc()
|
|
-- CHECK(not wref[1])
|
|
-- CHECK(not wref[2])
|
|
-- end
|
|
end
|
|
|
|
do CASE "garbage collection (binded)"
|
|
local input = source(10)
|
|
local output = spring(input, 1, 1)
|
|
|
|
local _label = create "TextLabel" {
|
|
Text = output
|
|
}
|
|
|
|
local wref = { output }
|
|
output = NIL
|
|
|
|
gc()
|
|
CHECK(wref[1]) -- `output` should not gc
|
|
end
|
|
|
|
do CASE "spring finished"
|
|
local input = source(0)
|
|
local output = spring(input)
|
|
|
|
input(1)
|
|
step(0.05)
|
|
CHECK(output() ~= input()) -- check spring is moving
|
|
step(10) -- spring finished, should be internally removed from queue
|
|
CHECK(output() == input()) -- check spring is at target
|
|
|
|
local count = -1
|
|
effect(function()
|
|
output()
|
|
count += 1
|
|
end)
|
|
|
|
step(1) -- attempt to cause another spring update
|
|
CHECK(count == 0) -- check no update occurs as spring is finished
|
|
--
|
|
|
|
gc() -- perform full gc
|
|
input(2) -- spring should be re-added to spring queue
|
|
step(0) -- process spring queue
|
|
CHECK(count == 1) -- check spring was rescheduled correctly
|
|
end
|
|
|
|
do CASE "spring control"
|
|
local input = source(1)
|
|
local output, control = spring(input)
|
|
|
|
local value = input()
|
|
local count = 0
|
|
effect(function()
|
|
value = output()
|
|
count += 1
|
|
end)
|
|
|
|
CHECK(count == 1)
|
|
CHECK(value == 1)
|
|
|
|
control { impulse = 1 }
|
|
|
|
CHECK(count == 1)
|
|
CHECK(value == 1)
|
|
|
|
step(1/120 + 0.001)
|
|
|
|
CHECK(count == 2)
|
|
CHECK(value > 1)
|
|
end
|
|
end))
|
|
|
|
TEST("untrack()", wrap_root(function()
|
|
do CASE "does not register dependency"
|
|
local a = source(0)
|
|
local b = source(0)
|
|
|
|
local count = 0
|
|
|
|
effect(function()
|
|
count += 1
|
|
untrack(a)
|
|
b()
|
|
end)
|
|
|
|
b(1)
|
|
CHECK(count == 2)
|
|
|
|
a(1)
|
|
CHECK(count == 2)
|
|
|
|
CHECK(a() == untrack(a))
|
|
end
|
|
|
|
do CASE "derived source"
|
|
local a = source(0)
|
|
local b = source(0)
|
|
local c = source(0)
|
|
|
|
local d = function()
|
|
return a() + b()
|
|
end
|
|
|
|
local count = 0
|
|
|
|
effect(function()
|
|
count += 1
|
|
untrack(d)
|
|
c()
|
|
end)
|
|
|
|
c(1)
|
|
CHECK(count == 2)
|
|
|
|
a(1)
|
|
b(1)
|
|
CHECK(count == 2)
|
|
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(destroy)
|
|
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, destroy
|
|
end)
|
|
|
|
CHECK(outer_count == 1)
|
|
CHECK(inner_count == 1)
|
|
CHECK(cleaned_count == 0)
|
|
|
|
local output2 = output()
|
|
|
|
CHECK(output2() == "0")
|
|
|
|
input(1)
|
|
|
|
CHECK(outer_count == 1)
|
|
CHECK(inner_count == 2)
|
|
CHECK(cleaned_count == 1)
|
|
|
|
local output3 = output()
|
|
CHECK(output2() == "1")
|
|
CHECK(output3() == "1")
|
|
|
|
CHECK(output2 == output3)
|
|
|
|
destroy()
|
|
|
|
CHECK(cleaned_count == 2)
|
|
end
|
|
end))
|
|
|
|
TEST("events", function()
|
|
local function Thing(props)
|
|
local instance = Instance.new("Thing")
|
|
instance.Signal = Signal.new()
|
|
|
|
local clone = create(instance)(props)
|
|
|
|
return clone
|
|
end
|
|
|
|
do CASE "connect event"
|
|
local connected = false
|
|
|
|
local val = Thing {
|
|
Signal = function(newval)
|
|
connected = true
|
|
CHECK(newval == 1)
|
|
end
|
|
}
|
|
|
|
CHECK(not connected)
|
|
val.Value = 1; Signal.fire(val.Signal, val.Value)
|
|
CHECK(connected)
|
|
end
|
|
end)
|
|
|
|
TEST("actions", function()
|
|
do CASE "run action"
|
|
local ran = false
|
|
|
|
local frame_ref
|
|
local frame = create "Frame" {
|
|
action(function(self)
|
|
frame_ref = self
|
|
ran = true
|
|
end, 1)
|
|
}
|
|
|
|
CHECK(ran)
|
|
CHECK(frame_ref == frame)
|
|
end
|
|
|
|
do CASE "priorities"
|
|
local queue = {}
|
|
|
|
create "Frame" {
|
|
action(function(self)
|
|
table.insert(queue, 2)
|
|
end, 2),
|
|
|
|
action(function(self)
|
|
table.insert(queue, 1)
|
|
end, 1)
|
|
}
|
|
|
|
CHECK(testkit.seq(queue, { 1, 2 }))
|
|
end
|
|
end)
|
|
|
|
TEST("changed()", wrap_root(function()
|
|
do CASE "outputs"
|
|
local output = source(nil)
|
|
|
|
local text = create "TextLabel" {
|
|
Text = "a",
|
|
changed("Text", output)
|
|
}
|
|
|
|
CHECK(output() == "a")
|
|
text.Text = "b"
|
|
CHECK(output() == "b")
|
|
end
|
|
|
|
do CASE "connection disconnected"
|
|
local _, text, destroy = root(function(destroy)
|
|
local output = source(nil)
|
|
|
|
return create "TextLabel" {
|
|
Text = "a",
|
|
changed("Text", output)
|
|
}, destroy
|
|
end)
|
|
|
|
destroy() -- changed() should of disconnect connection
|
|
|
|
-- check if instance can gc
|
|
local wref = weak { text }
|
|
text = NIL
|
|
gc()
|
|
CHECK(not wref[1])
|
|
end
|
|
end))
|
|
|
|
TEST("tag()", wrap_root(function()
|
|
do CASE "apply single tag"
|
|
local frame = create "Frame" {
|
|
tag("MyTag")
|
|
}
|
|
|
|
CHECK(frame:HasTag("MyTag"))
|
|
CHECK(#frame:GetTags() == 1)
|
|
end
|
|
|
|
do CASE "apply multiple tags"
|
|
local frame = create "Frame" {
|
|
tag({ "A", "B", "C" })
|
|
}
|
|
|
|
CHECK(frame:HasTag("A"))
|
|
CHECK(frame:HasTag("B"))
|
|
CHECK(frame:HasTag("C"))
|
|
CHECK(#frame:GetTags() == 3)
|
|
end
|
|
|
|
do CASE "compose multiple tag() actions"
|
|
local frame = create "Frame" {
|
|
tag("A"),
|
|
tag("B")
|
|
}
|
|
|
|
CHECK(frame:HasTag("A"))
|
|
CHECK(frame:HasTag("B"))
|
|
end
|
|
|
|
do CASE "remove tags on scope destroy"
|
|
local _, frame, destroy = root(function(destroy)
|
|
return create "Frame" {
|
|
tag({ "A", "B" })
|
|
}, destroy
|
|
end)
|
|
|
|
CHECK(frame:HasTag("A"))
|
|
CHECK(frame:HasTag("B"))
|
|
|
|
destroy()
|
|
|
|
CHECK(not frame:HasTag("A"))
|
|
CHECK(not frame:HasTag("B"))
|
|
end
|
|
|
|
do CASE "reactive single tag"
|
|
local current = source("A")
|
|
|
|
local frame = create "Frame" {
|
|
tag(current)
|
|
}
|
|
|
|
CHECK(frame:HasTag("A"))
|
|
CHECK(#frame:GetTags() == 1)
|
|
|
|
current("B")
|
|
|
|
CHECK(not frame:HasTag("A"))
|
|
CHECK(frame:HasTag("B"))
|
|
CHECK(#frame:GetTags() == 1)
|
|
end
|
|
|
|
do CASE "reactive multiple tags"
|
|
local current = source({ "A", "B" })
|
|
|
|
local frame = create "Frame" {
|
|
tag(current)
|
|
}
|
|
|
|
CHECK(frame:HasTag("A"))
|
|
CHECK(frame:HasTag("B"))
|
|
CHECK(#frame:GetTags() == 2)
|
|
|
|
-- preserve "A", remove "B", add "C"
|
|
current({ "A", "C" })
|
|
|
|
CHECK(frame:HasTag("A"))
|
|
CHECK(not frame:HasTag("B"))
|
|
CHECK(frame:HasTag("C"))
|
|
CHECK(#frame:GetTags() == 2)
|
|
end
|
|
|
|
do CASE "reactive tag removed on scope destroy"
|
|
local _, frame, destroy = root(function(destroy)
|
|
local current = source("A")
|
|
return create "Frame" {
|
|
tag(current)
|
|
}, destroy
|
|
end)
|
|
|
|
CHECK(frame:HasTag("A"))
|
|
|
|
destroy()
|
|
|
|
CHECK(not frame:HasTag("A"))
|
|
CHECK(#frame:GetTags() == 0)
|
|
end
|
|
|
|
do CASE "reactive tag derived from source"
|
|
local name = source("hello")
|
|
|
|
local frame = create "Frame" {
|
|
tag(function() return "prefix-" .. name() end)
|
|
}
|
|
|
|
CHECK(frame:HasTag("prefix-hello"))
|
|
|
|
name("world")
|
|
|
|
CHECK(not frame:HasTag("prefix-hello"))
|
|
CHECK(frame:HasTag("prefix-world"))
|
|
end
|
|
end))
|
|
|
|
TEST("batch()", wrap_root(function()
|
|
do CASE "evaluation deferred"
|
|
local a = source(0)
|
|
|
|
local count = { b = 0, b2 = 0, c = 0 }
|
|
|
|
local b = derive(function()
|
|
count.b += 1
|
|
return a() + 1
|
|
end)
|
|
|
|
local b2 = derive(function()
|
|
count.b2 += 1
|
|
return a() + 2
|
|
end)
|
|
|
|
local c = derive(function()
|
|
count.c += 1
|
|
return b() + b2()
|
|
end)
|
|
|
|
batch(function()
|
|
a(1)
|
|
CHECK(count.b == 1)
|
|
CHECK(count.b2 == 1)
|
|
CHECK(count.c == 1)
|
|
end)
|
|
|
|
CHECK(count.b == 2)
|
|
CHECK(count.b2 == 2)
|
|
CHECK(count.c == 2)
|
|
|
|
CHECK(b() == 2)
|
|
CHECK(c() == 5)
|
|
end
|
|
|
|
do CASE "recursive call"
|
|
local a1 = source(0)
|
|
local a2 = source(0)
|
|
local a3 = source(0)
|
|
|
|
local count = { b1 = 0, b2 = 0, b3 = 0 }
|
|
|
|
local b1 = derive(function()
|
|
count.b1 += 1
|
|
return a1() + 1
|
|
end)
|
|
|
|
local b2 = derive(function()
|
|
count.b2 += 1
|
|
return a2() + 1
|
|
end)
|
|
|
|
local b3 = derive(function()
|
|
count.b3 += 1
|
|
return a3() + 1
|
|
end)
|
|
|
|
batch(function()
|
|
a1(1)
|
|
batch(function()
|
|
a2(2)
|
|
end)
|
|
a3(3)
|
|
CHECK(count.b1 == 1)
|
|
CHECK(count.b2 == 1)
|
|
CHECK(count.b3 == 1)
|
|
end)
|
|
|
|
CHECK(count.b1 == 2)
|
|
CHECK(count.b2 == 2)
|
|
CHECK(count.b3 == 2)
|
|
|
|
CHECK(b1() == 2)
|
|
CHECK(b2() == 3)
|
|
CHECK(b3() == 4)
|
|
end
|
|
|
|
do CASE "subsequent updates do not batch"
|
|
|
|
local a = source(0)
|
|
local b = source(0)
|
|
local c = source(0)
|
|
local d_n = 0
|
|
|
|
effect(function()
|
|
b()
|
|
c()
|
|
d_n += 1
|
|
end)
|
|
|
|
effect(function()
|
|
b(a())
|
|
c(a())
|
|
end)
|
|
|
|
batch(function()
|
|
a(1)
|
|
end)
|
|
|
|
CHECK(d_n == 3)
|
|
|
|
end
|
|
|
|
do CASE "recursive queue flush diamond A,B,C,D"
|
|
--[[
|
|
|
|
a > b > d
|
|
> c >
|
|
|
|
]]
|
|
|
|
local a = source(0)
|
|
|
|
local b = source(0)
|
|
local c = source(0)
|
|
local d = source(0)
|
|
|
|
local count = { b = 0, c = 0, d = 0 }
|
|
effect(function()
|
|
batch(function()
|
|
b(a() % 2 == 0 and 1 or 0)
|
|
c(a() * 2)
|
|
end)
|
|
count.b += 1
|
|
count.c += 1
|
|
end)
|
|
|
|
effect(function()
|
|
batch(function()
|
|
d(b() + c())
|
|
end)
|
|
count.d += 1
|
|
end)
|
|
|
|
a(1)
|
|
CHECK(count.b == 2)
|
|
CHECK(count.c == 2)
|
|
CHECK(count.d == 2)
|
|
CHECK(d() == 2)
|
|
|
|
a(3)
|
|
CHECK(count.b == 3)
|
|
CHECK(count.c == 3)
|
|
CHECK(count.d == 3)
|
|
CHECK(d() == 6)
|
|
end
|
|
|
|
do CASE "recursive queue flush diamond A,B,C,D,E"
|
|
--[[
|
|
where b and c batches d
|
|
|
|
a > b > e
|
|
> c > d >
|
|
|
|
]]
|
|
|
|
local a = source(0)
|
|
|
|
local b = source(0)
|
|
local c = source(0)
|
|
local d = source(0)
|
|
local e = source(0)
|
|
|
|
local count = { b = 0, c = 0, d = 0, e = 0 }
|
|
effect(function()
|
|
batch(function()
|
|
b(a() % 2 == 0 and 1 or 0)
|
|
c(a() * 2)
|
|
end)
|
|
count.b += 1
|
|
count.c += 1
|
|
end)
|
|
|
|
effect(function()
|
|
batch(function()
|
|
d(c() * 2)
|
|
end)
|
|
count.d += 1
|
|
end)
|
|
|
|
effect(function()
|
|
batch(function()
|
|
e(b() + d())
|
|
end)
|
|
count.e += 1
|
|
end)
|
|
|
|
CHECK(e() == 1)
|
|
|
|
a(1)
|
|
|
|
CHECK(count.b == 2)
|
|
CHECK(count.c == 2)
|
|
CHECK(count.d == 2)
|
|
CHECK(count.e == 3)
|
|
CHECK(e() == 4)
|
|
|
|
a(3)
|
|
CHECK(count.b == 3)
|
|
CHECK(count.c == 3)
|
|
CHECK(count.d == 3)
|
|
CHECK(count.e == 4)
|
|
CHECK(e() == 12)
|
|
|
|
end
|
|
|
|
do CASE "recursive queue flush diamond A,B,C,D,E,F,G"
|
|
--[[
|
|
|
|
a > b > d > E > G
|
|
> c ^ > F
|
|
|
|
]]
|
|
|
|
local a = source(0)
|
|
|
|
local b = source(0)
|
|
local c = source(0)
|
|
local d = source(0)
|
|
|
|
local e = source(0)
|
|
local f = source(0)
|
|
local g = source(0)
|
|
|
|
local count = { b = 0, c = 0, d = 0, e = 0, f = 0, g = 0 }
|
|
effect(function()
|
|
batch(function()
|
|
b(a() % 2 == 0 and 1 or 0)
|
|
c(a() * 2)
|
|
end)
|
|
count.b += 1
|
|
count.c += 1
|
|
end)
|
|
|
|
effect(function()
|
|
batch(function()
|
|
d(b() + c())
|
|
end)
|
|
count.d += 1
|
|
end)
|
|
|
|
effect(function()
|
|
batch(function()
|
|
e(d() % 2 == 0 and 1 or 0)
|
|
f(d() * 2)
|
|
end)
|
|
count.e += 1
|
|
count.f += 1
|
|
end)
|
|
|
|
effect(function()
|
|
batch(function()
|
|
g(e() + f())
|
|
end)
|
|
count.g += 1
|
|
end)
|
|
|
|
a(1)
|
|
CHECK(count.b == 2)
|
|
CHECK(count.c == 2)
|
|
CHECK(count.d == 2)
|
|
CHECK(count.e == 2)
|
|
CHECK(count.f == 2)
|
|
CHECK(count.g == 2)
|
|
CHECK(d() == 2)
|
|
CHECK(g() == 5)
|
|
|
|
a(3)
|
|
CHECK(count.b == 3)
|
|
CHECK(count.c == 3)
|
|
CHECK(count.d == 3)
|
|
CHECK(count.e == 3)
|
|
CHECK(count.f == 3)
|
|
CHECK(count.g == 3)
|
|
CHECK(d() == 6)
|
|
CHECK(g() == 13)
|
|
|
|
|
|
end
|
|
|
|
end))
|
|
|
|
TEST("read()", wrap_root(function()
|
|
do CASE "read primitive"
|
|
CHECK(read(1) == 1)
|
|
end
|
|
|
|
do CASE "read source"
|
|
local src = source(1) :: () -> number
|
|
CHECK(read(src) == 1)
|
|
end
|
|
|
|
do CASE "push_scope_as_child_of source"
|
|
local src = source(0)
|
|
|
|
local count = 0
|
|
effect(function()
|
|
read(src)
|
|
count += 1
|
|
end)
|
|
|
|
src(1)
|
|
CHECK(count == 2)
|
|
end
|
|
end))
|
|
|
|
TEST("context()", function()
|
|
do CASE "set context"
|
|
local ctx = context()
|
|
|
|
root(function()
|
|
ctx(1, function()
|
|
CHECK(ctx() == 1)
|
|
|
|
effect(function()
|
|
CHECK(ctx() == 1)
|
|
end)
|
|
end)
|
|
end)
|
|
end
|
|
|
|
do CASE "set context outside of scope"
|
|
local ctx = context()
|
|
|
|
local ok = pcall(function()
|
|
ctx(1, function() end)
|
|
end)
|
|
|
|
CHECK(not ok)
|
|
end
|
|
|
|
do CASE "get default context"
|
|
local ctx = context(1)
|
|
|
|
CHECK(ctx() == 1)
|
|
|
|
root(function()
|
|
local v = ctx(2, function()
|
|
CHECK(ctx() == 2)
|
|
return ctx()
|
|
end)
|
|
|
|
CHECK(v == 2)
|
|
CHECK(ctx() == 1)
|
|
end)
|
|
end
|
|
|
|
do CASE "context cascade"
|
|
local ctx = context(1)
|
|
local ctx2 = context()
|
|
|
|
root(function()
|
|
ctx(2, function()
|
|
ctx2(true, function()
|
|
show(function() return true end, function()
|
|
ctx(3, function()
|
|
effect(function()
|
|
CHECK(ctx() == 3)
|
|
untrack(function()
|
|
effect(function()
|
|
CHECK(ctx() == 3)
|
|
end)
|
|
CHECK(ctx2() == true)
|
|
return {}
|
|
end)
|
|
end)
|
|
CHECK(ctx() == 3)
|
|
end)
|
|
CHECK(ctx() == 2)
|
|
return {}
|
|
end)
|
|
end)
|
|
CHECK(ctx() == 2)
|
|
end)
|
|
|
|
CHECK(ctx() == 1)
|
|
end)
|
|
end
|
|
|
|
do CASE "nil context"
|
|
local ctx = context(nil)
|
|
|
|
root(function()
|
|
CHECK(ctx() == nil)
|
|
ctx(nil, function()
|
|
CHECK(ctx() == nil)
|
|
ctx(true :: any, function()
|
|
CHECK(ctx() == true)
|
|
ctx(nil, function()
|
|
effect(function()
|
|
untrack(function()
|
|
effect(function()
|
|
CHECK(ctx() == nil)
|
|
end)
|
|
return {}
|
|
end)
|
|
end)
|
|
end)
|
|
end)
|
|
end)
|
|
end)
|
|
end
|
|
end)
|
|
|
|
TEST("nested effects cases", function()
|
|
local ran = 0
|
|
local cleaned = 0
|
|
|
|
local function Count()
|
|
local count = source(0)
|
|
|
|
effect(function()
|
|
count()
|
|
ran += 1
|
|
cleanup(function() cleaned += 1 end)
|
|
end)
|
|
|
|
return nil
|
|
end
|
|
|
|
local function App(destroy)
|
|
local name = source "a"
|
|
|
|
effect(function()
|
|
name()
|
|
untrack(Count)
|
|
end)
|
|
|
|
CHECK(ran == 1)
|
|
CHECK(cleaned == 0)
|
|
|
|
name "b"
|
|
|
|
CHECK(ran == 2)
|
|
CHECK(cleaned == 1)
|
|
|
|
destroy()
|
|
|
|
CHECK(ran == 2)
|
|
CHECK(cleaned == 2)
|
|
end
|
|
|
|
root(App)
|
|
end)
|
|
|
|
TEST("graph edge cases", wrap_root(function()
|
|
do CASE "diamond A,B,C,D"
|
|
--[[
|
|
|
|
a > b > d
|
|
> c >
|
|
|
|
]]
|
|
|
|
local a = source(0)
|
|
|
|
local b = derive(function() return (a() % 2 == 0) and 1 or 0 end)
|
|
local c = derive(function() return a() * 2 end)
|
|
local d = derive(function() return b() + c() end)
|
|
|
|
local count = { b = 0, c = 0, d = 0 }
|
|
effect(function() b(); count.b += 1 end)
|
|
effect(function() c(); count.c += 1 end)
|
|
effect(function() d(); count.d += 1 end)
|
|
|
|
a(1)
|
|
CHECK(count.b == 2)
|
|
CHECK(count.c == 2)
|
|
CHECK(count.d == 2)
|
|
CHECK(d() == 2)
|
|
|
|
a(3)
|
|
CHECK(count.b == 2)
|
|
CHECK(count.c == 3)
|
|
CHECK(count.d == 3)
|
|
CHECK(d() == 6)
|
|
end
|
|
|
|
do CASE "diamond A,B,C,D,E"
|
|
--[[
|
|
|
|
a > b > e
|
|
> c > d >
|
|
|
|
]]
|
|
|
|
local a = source(0)
|
|
|
|
local b = derive(function() return (a() % 2 == 0) and 1 or 0 end)
|
|
local c = derive(function() return a() * 2 end)
|
|
local d = derive(function() return c() * 2 end)
|
|
local e = derive(function() return b() + d() end)
|
|
|
|
local count = { b = 0, c = 0, d = 0, e = 0 }
|
|
effect(function() b(); count.b += 1 end)
|
|
effect(function() c(); count.c += 1 end)
|
|
effect(function() d(); count.d += 1 end)
|
|
effect(function() e(); count.e += 1 end)
|
|
|
|
CHECK(e() == 1)
|
|
|
|
a(1)
|
|
|
|
CHECK(count.b == 2)
|
|
CHECK(count.c == 2)
|
|
CHECK(count.d == 2)
|
|
CHECK(count.e == 3) -- todo: redundant re-eval
|
|
CHECK(e() == 4)
|
|
|
|
a(3)
|
|
CHECK(count.b == 2)
|
|
CHECK(count.c == 3)
|
|
CHECK(count.d == 3)
|
|
CHECK(count.e == 4)
|
|
CHECK(e() == 12)
|
|
end
|
|
|
|
do CASE "repeated read"
|
|
local a = source(0)
|
|
local b = derive(function() return a() + a() end)
|
|
|
|
local count = 0
|
|
effect(function() b(); count += 1 end)
|
|
|
|
a(1)
|
|
CHECK(b() == 2)
|
|
CHECK(count == 2)
|
|
end
|
|
|
|
do CASE "do not destroy children"
|
|
local parent = source(0)
|
|
|
|
local _,
|
|
destroy,
|
|
parent_to_destroy,
|
|
update_parent_to_destroy
|
|
= root(function(destroy)
|
|
local src = source(0)
|
|
return
|
|
destroy,
|
|
derive(function() return src() end),
|
|
src
|
|
end)
|
|
|
|
local count = 0
|
|
|
|
effect(function()
|
|
count += 1
|
|
parent()
|
|
parent_to_destroy()
|
|
end)
|
|
|
|
parent(parent() + 1)
|
|
CHECK(count == 2)
|
|
update_parent_to_destroy(1)
|
|
CHECK(count == 3)
|
|
|
|
destroy()
|
|
|
|
update_parent_to_destroy(2)
|
|
CHECK(count == 3)
|
|
|
|
parent(parent() + 1)
|
|
CHECK(count == 4)
|
|
end
|
|
|
|
do CASE "double destroy"
|
|
-- issue:
|
|
-- parent evaluates
|
|
-- child A queued
|
|
-- child B queued
|
|
-- child A destroys child B
|
|
-- child B reevaluates due to already being queued
|
|
-- parent destroys, destroys child B - uh oh
|
|
|
|
local _,
|
|
destroy_parent,
|
|
parent,
|
|
update_parent
|
|
= root(function(destroy)
|
|
local src = source(0)
|
|
return
|
|
destroy,
|
|
derive(function() return src() end),
|
|
src
|
|
end)
|
|
|
|
local _, destroy_child, _child_B = nil, function() end, nil
|
|
|
|
local count_A = 0
|
|
|
|
-- child_A
|
|
effect(function()
|
|
count_A += 1
|
|
parent()
|
|
destroy_child()
|
|
end)
|
|
|
|
local count_B = 0
|
|
destroy_child, _child_B = root(function(destroy)
|
|
return
|
|
destroy,
|
|
derive(function() count_B += 1; return parent() end)
|
|
end)
|
|
|
|
update_parent(parent() + 1)
|
|
CHECK(count_A == 2)
|
|
CHECK(count_B == 1) -- child B should not run again
|
|
destroy_parent() -- should not error
|
|
CHECK(true)
|
|
end
|
|
end))
|
|
|
|
TEST("strict", wrap_root(function()
|
|
vide.strict = true
|
|
|
|
do CASE "error on derived callback yield"
|
|
local src = source(1)
|
|
|
|
local ok = pcall(function()
|
|
local _derived = derive(function()
|
|
coroutine.yield()
|
|
return src()
|
|
end)
|
|
end)
|
|
|
|
CHECK(not ok)
|
|
end
|
|
|
|
do CASE "error on effecter callback yield"
|
|
local src = source(1)
|
|
|
|
local ok = pcall(function()
|
|
effect(function()
|
|
coroutine.yield()
|
|
src()
|
|
end)
|
|
end)
|
|
|
|
CHECK(not ok)
|
|
end
|
|
|
|
do CASE "run derived callback twice"
|
|
local src = source(1)
|
|
local count = 0
|
|
|
|
local _ = derive(function()
|
|
count += 1
|
|
return src()
|
|
end)
|
|
|
|
CHECK(count == 2)
|
|
src(2)
|
|
CHECK(count == 4)
|
|
end
|
|
|
|
do CASE "run effect callback twice"
|
|
local src = source(1)
|
|
local count = 0
|
|
|
|
effect(function()
|
|
count += 1
|
|
src()
|
|
end)
|
|
|
|
CHECK(count == 2)
|
|
src(2)
|
|
CHECK(count == 4)
|
|
end
|
|
|
|
do CASE "values() error if duplicate"
|
|
local src = source { 1, 2, 1 }
|
|
|
|
local ok = pcall(function()
|
|
values(src, function() return {} end)
|
|
end)
|
|
|
|
CHECK(not ok)
|
|
end
|
|
|
|
do CASE "duplicate properties"
|
|
local ok = pcall(function()
|
|
create "TextLabel" {
|
|
{
|
|
Name = "foo"
|
|
},
|
|
{
|
|
Name = "bar"
|
|
}
|
|
}
|
|
end)
|
|
|
|
CHECK(not ok)
|
|
|
|
ok = pcall(function()
|
|
create "TextLabel" {
|
|
{
|
|
Name = "foo",
|
|
{
|
|
Name = "bar"
|
|
}
|
|
}
|
|
}
|
|
end)
|
|
|
|
CHECK(ok)
|
|
end
|
|
|
|
do CASE "effect counter"
|
|
local src = source(true)
|
|
|
|
local count = 0
|
|
|
|
effect(function(x: number)
|
|
src()
|
|
count = x + 1
|
|
return count
|
|
end, count)
|
|
|
|
CHECK(count == 2)
|
|
src(not src())
|
|
CHECK(count == 4)
|
|
end
|
|
|
|
do CASE "effect using derived source"
|
|
local input = source(true)
|
|
|
|
local output = derive(function()
|
|
return input()
|
|
end)
|
|
|
|
local count = 0
|
|
|
|
effect(function()
|
|
output()
|
|
count += 1
|
|
end)
|
|
|
|
CHECK(count == 2)
|
|
|
|
input(false)
|
|
|
|
CHECK(count == 4)
|
|
end
|
|
|
|
do CASE "destruction of active scope"
|
|
local src = source(false)
|
|
local count = 0
|
|
|
|
root(function()
|
|
show(src, function()
|
|
src(false)
|
|
cleanup(function() count += 1 end)
|
|
return {}
|
|
end)
|
|
end)
|
|
|
|
local ok = pcall(function()
|
|
src(true)
|
|
end)
|
|
|
|
CHECK(count == 0)
|
|
CHECK(not ok)
|
|
end
|
|
|
|
do CASE "destruction of active scope in indexes"
|
|
local src = source {}
|
|
|
|
local count_1 = 0
|
|
local count_2 = 0
|
|
|
|
root(function()
|
|
effect(function()
|
|
untrack(function()
|
|
indexes(src, function()
|
|
cleanup(function() count_1 += 1 end)
|
|
src {}
|
|
cleanup(function() count_2 += 1 end)
|
|
return {}
|
|
end)
|
|
end)
|
|
end)
|
|
end)
|
|
|
|
local ok = pcall(function()
|
|
src { 1 }
|
|
end)
|
|
|
|
CHECK(not ok)
|
|
end
|
|
|
|
do CASE "destruction of active scope in values"
|
|
local src = source {}
|
|
|
|
local count_1 = 0
|
|
local count_2 = 0
|
|
|
|
root(function()
|
|
effect(function()
|
|
untrack(function()
|
|
values(src, function()
|
|
cleanup(function() count_1 += 1 end)
|
|
src {}
|
|
cleanup(function() count_2 += 1 end)
|
|
return {}
|
|
end)
|
|
end)
|
|
end)
|
|
end)
|
|
|
|
local ok = pcall(function()
|
|
src { {} }
|
|
end)
|
|
|
|
CHECK(not ok)
|
|
end
|
|
end))
|
|
|
|
local ok = FINISH()
|
|
if not ok then error("Tests failed", 0) end
|
|
|
|
return nil
|