mirror of
https://github.com/littensy/charm.git
synced 2026-10-21 03:36:11 +00:00
feat: initial commit
This commit is contained in:
commit
18723c1377
50 changed files with 3238 additions and 0 deletions
85
src/__tests__/atom.spec.luau
Normal file
85
src/__tests__/atom.spec.luau
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
local atom = require(script.Parent.Parent.atom)
|
||||
|
||||
return function()
|
||||
it("updates state", function()
|
||||
local source = atom(1)
|
||||
source(2)
|
||||
expect(source()).to.equal(2)
|
||||
source(function(x)
|
||||
return x + 1
|
||||
end)
|
||||
expect(source()).to.equal(3)
|
||||
end)
|
||||
|
||||
it("calls listeners", function()
|
||||
local source = atom(1)
|
||||
local value
|
||||
local function listener(state)
|
||||
value = state
|
||||
end
|
||||
source.listeners[listener] = true
|
||||
source(2)
|
||||
expect(value).to.equal(2)
|
||||
end)
|
||||
|
||||
it("skips pending listeners", function()
|
||||
local source = atom(1)
|
||||
local outer = 0
|
||||
local inner = 0
|
||||
local function listener()
|
||||
outer += 1
|
||||
local function listener()
|
||||
inner += 1
|
||||
end
|
||||
source.listeners[listener] = true
|
||||
end
|
||||
source.listeners[listener] = true
|
||||
source(2)
|
||||
expect(outer).to.equal(1)
|
||||
expect(inner).to.equal(0)
|
||||
source(3)
|
||||
expect(outer).to.equal(2)
|
||||
expect(inner).to.equal(1)
|
||||
end)
|
||||
|
||||
it("can be readonly", function()
|
||||
local readonly = atom(1):readonly()
|
||||
|
||||
expect(function()
|
||||
(readonly :: any)(2)
|
||||
end).to.throw()
|
||||
|
||||
local writable = readonly:writable()
|
||||
|
||||
writable(2)
|
||||
expect(writable()).to.equal(2)
|
||||
end)
|
||||
|
||||
it("calls the setter", function()
|
||||
local source = atom(1):writable(function(_, value: number)
|
||||
return value + 1
|
||||
end)
|
||||
source(1)
|
||||
expect(source()).to.equal(2)
|
||||
end)
|
||||
|
||||
it("cancels if setter returns nil", function()
|
||||
local source = atom(1):writable(function(value: number)
|
||||
return if value == 2 then nil else value
|
||||
end)
|
||||
source(2)
|
||||
expect(source()).to.equal(1)
|
||||
source(3)
|
||||
expect(source()).to.equal(3)
|
||||
end)
|
||||
|
||||
it("can be memoized", function()
|
||||
local source = atom(1):memo(function(prev, next)
|
||||
return next < prev
|
||||
end)
|
||||
source(2)
|
||||
expect(source()).to.equal(2)
|
||||
source(1)
|
||||
expect(source()).to.equal(2)
|
||||
end)
|
||||
end
|
||||
92
src/__tests__/derive.spec.luau
Normal file
92
src/__tests__/derive.spec.luau
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
local atom = require(script.Parent.Parent.atom)
|
||||
local derive = require(script.Parent.Parent.derive)
|
||||
|
||||
return function()
|
||||
it("derives from dependencies", function()
|
||||
local a = atom(1)
|
||||
local b = atom(2)
|
||||
local sum = derive(a, b, function(a: number, b: number)
|
||||
return a + b
|
||||
end)
|
||||
expect(sum()).to.equal(3)
|
||||
a(2)
|
||||
expect(sum()).to.equal(4)
|
||||
b(3)
|
||||
expect(sum()).to.equal(5)
|
||||
expect(function()
|
||||
(sum :: any)(2)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("memoizes the result", function()
|
||||
local calls = 0
|
||||
local source = atom(1)
|
||||
local double = derive(source, function(value: number)
|
||||
calls += 1
|
||||
return value * 2
|
||||
end)
|
||||
expect(calls).to.equal(1)
|
||||
source(1)
|
||||
expect(calls).to.equal(1)
|
||||
source(2)
|
||||
expect(calls).to.equal(2)
|
||||
double()
|
||||
expect(calls).to.equal(2)
|
||||
end)
|
||||
|
||||
it("can be a dependency", function()
|
||||
local source = atom(1)
|
||||
local double = derive(source, function(value: number)
|
||||
return value * 2
|
||||
end)
|
||||
local quadruple = derive(double, function(value: number)
|
||||
return value * 2
|
||||
end)
|
||||
expect(quadruple()).to.equal(4)
|
||||
source(2)
|
||||
expect(quadruple()).to.equal(8)
|
||||
end)
|
||||
|
||||
it("allows custom setter", function()
|
||||
local source = atom(1)
|
||||
local double = derive(source, function(value: number)
|
||||
return value * 2
|
||||
end):writable(function(update: number)
|
||||
source(update / 2)
|
||||
end)
|
||||
double(4)
|
||||
expect(source()).to.equal(2)
|
||||
expect(double()).to.equal(4)
|
||||
end)
|
||||
|
||||
it("garbage collects", function()
|
||||
-- large values coerce the garbage collector to run
|
||||
local source = atom(string.rep("x", 1e5))
|
||||
local max = 0
|
||||
|
||||
local function listenersDecreased()
|
||||
local count = 0
|
||||
for _ in source.listeners do
|
||||
count += 1
|
||||
end
|
||||
max = math.max(max, count)
|
||||
return count < max
|
||||
end
|
||||
|
||||
for index = 1, 500 do
|
||||
derive(source, function(value)
|
||||
return string.upper(value)
|
||||
end)
|
||||
if index % 50 == 0 then
|
||||
-- derived atoms check if they are collected when they are updated
|
||||
source(source() .. "x")
|
||||
task.wait()
|
||||
end
|
||||
if listenersDecreased() then
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
error("None of the derived atoms were collected")
|
||||
end)
|
||||
end
|
||||
7
src/__tests__/init.spec.luau
Normal file
7
src/__tests__/init.spec.luau
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
local reference = require(script.Parent.Parent.utils.reference)
|
||||
|
||||
return function()
|
||||
afterEach(function()
|
||||
reference.clear()
|
||||
end)
|
||||
end
|
||||
33
src/__tests__/observe.spec.luau
Normal file
33
src/__tests__/observe.spec.luau
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
local atom = require(script.Parent.Parent.atom)
|
||||
local observe = require(script.Parent.Parent.observe)
|
||||
|
||||
return function()
|
||||
it("observes lifetime", function()
|
||||
local itemsAtom = atom({ "a", "b", "c" })
|
||||
local state = {}
|
||||
|
||||
local cleanup, flush = observe(itemsAtom, function(item, key)
|
||||
state[key] = item
|
||||
return function()
|
||||
state[key] = nil
|
||||
end
|
||||
end)
|
||||
|
||||
for key = 1, 3 do
|
||||
expect(state[key]).to.equal(itemsAtom:get()[key])
|
||||
end
|
||||
|
||||
itemsAtom:set({ [4] = "d", [5] = "e", [6] = "f" })
|
||||
flush()
|
||||
|
||||
for key = 1, 6 do
|
||||
expect(state[key]).to.equal(itemsAtom:get()[key])
|
||||
end
|
||||
|
||||
cleanup()
|
||||
|
||||
for key = 1, 6 do
|
||||
expect(state[key]).to.equal(nil)
|
||||
end
|
||||
end)
|
||||
end
|
||||
42
src/__tests__/react/helpers/renderHook.luau
Normal file
42
src/__tests__/react/helpers/renderHook.luau
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
_G.__ROACT_17_MOCK_SCHEDULER__ = true
|
||||
|
||||
local Package = script:FindFirstAncestor("__tests__").Parent
|
||||
|
||||
local React = require(Package.modules.React)
|
||||
local ReactRoblox = require(Package.modules.ReactRoblox)
|
||||
|
||||
type RenderedHook<Props, Result> = {
|
||||
result: Result,
|
||||
rerender: (Props?) -> (),
|
||||
unmount: () -> (),
|
||||
}
|
||||
|
||||
local function renderHook<Props, Result>(callback: (Props) -> Result, props: Props?): RenderedHook<Props, Result>
|
||||
local root = ReactRoblox.createLegacyRoot(Instance.new("Folder"))
|
||||
local hook = {}
|
||||
|
||||
local function Component(props)
|
||||
hook.result = callback(props)
|
||||
end
|
||||
|
||||
function hook.rerender(newProps)
|
||||
props = newProps or props
|
||||
ReactRoblox.act(function()
|
||||
root:render(React.createElement(Component, props))
|
||||
end)
|
||||
end
|
||||
|
||||
function hook.unmount()
|
||||
ReactRoblox.act(function()
|
||||
root:unmount()
|
||||
end)
|
||||
end
|
||||
|
||||
ReactRoblox.act(function()
|
||||
hook.rerender(props)
|
||||
end)
|
||||
|
||||
return hook
|
||||
end
|
||||
|
||||
return renderHook
|
||||
27
src/__tests__/react/useAtomState.spec.luau
Normal file
27
src/__tests__/react/useAtomState.spec.luau
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
local atom = require(script.Parent.Parent.Parent.atom)
|
||||
local useAtomState = require(script.Parent.Parent.Parent.react.useAtomState)
|
||||
local renderHook = require(script.Parent.helpers.renderHook)
|
||||
|
||||
return function()
|
||||
it("tracks atom state", function()
|
||||
local source = atom(0)
|
||||
local hook = renderHook(function()
|
||||
return useAtomState(source)
|
||||
end)
|
||||
expect(hook.result).to.equal(0)
|
||||
source(1)
|
||||
expect(hook.result).to.equal(1)
|
||||
end)
|
||||
|
||||
it("receives a selector", function()
|
||||
local source = atom(0)
|
||||
local hook = renderHook(function()
|
||||
return useAtomState(source, function(value)
|
||||
return 2 * value
|
||||
end)
|
||||
end)
|
||||
expect(hook.result).to.equal(0)
|
||||
source(1)
|
||||
expect(hook.result).to.equal(2)
|
||||
end)
|
||||
end
|
||||
34
src/__tests__/react/useSetAtom.spec.luau
Normal file
34
src/__tests__/react/useSetAtom.spec.luau
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
local atom = require(script.Parent.Parent.Parent.atom)
|
||||
local useSetAtom = require(script.Parent.Parent.Parent.react.useSetAtom)
|
||||
local renderHook = require(script.Parent.helpers.renderHook)
|
||||
|
||||
return function()
|
||||
it("sets atom state", function()
|
||||
local source = atom(0)
|
||||
local hook = renderHook(function()
|
||||
return useSetAtom(source)
|
||||
end)
|
||||
expect(hook.result).to.be.a("function")
|
||||
hook.result(1)
|
||||
expect(source()).to.equal(1)
|
||||
end)
|
||||
|
||||
it("returns a stable function", function()
|
||||
local source = atom(0)
|
||||
local hook = renderHook(function()
|
||||
return useSetAtom(source)
|
||||
end)
|
||||
local fn = hook.result
|
||||
hook.rerender()
|
||||
expect(hook.result).to.equal(fn)
|
||||
end)
|
||||
|
||||
it("memoizes the function", function()
|
||||
local hook = renderHook(function(source)
|
||||
return useSetAtom(source)
|
||||
end, atom(0))
|
||||
local fn = hook.result
|
||||
hook.rerender(atom(1))
|
||||
expect(hook.result).to.never.equal(fn)
|
||||
end)
|
||||
end
|
||||
128
src/__tests__/split.spec.luau
Normal file
128
src/__tests__/split.spec.luau
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
local atom = require(script.Parent.Parent.atom)
|
||||
local isAtom = require(script.Parent.Parent.isAtom)
|
||||
local observe = require(script.Parent.Parent.observe)
|
||||
local split = require(script.Parent.Parent.split)
|
||||
|
||||
return function()
|
||||
it("returns split atoms", function()
|
||||
local itemsAtom = atom({ "a", "b", "c" })
|
||||
local itemAtoms = split(itemsAtom)
|
||||
local updates = 0
|
||||
|
||||
itemAtoms.listeners[function()
|
||||
updates += 1
|
||||
end] = true
|
||||
|
||||
for key = 1, 3 do
|
||||
local itemAtom = itemAtoms()[key]
|
||||
assert(isAtom(itemAtom), `itemAtom {key} is not an atom; got {typeof(itemAtom)}`)
|
||||
expect(itemAtom()).to.equal(itemsAtom()[key])
|
||||
end
|
||||
|
||||
local prevAtoms = itemAtoms()
|
||||
itemsAtom({ "d", "e", "f" })
|
||||
expect(updates).to.equal(1)
|
||||
|
||||
for key = 1, 3 do
|
||||
local itemAtom = itemAtoms()[key]
|
||||
assert(itemAtom == prevAtoms[key] :: any, `itemAtom {key} is not the same atom; got {typeof(itemAtom)}`)
|
||||
expect(itemAtom()).to.equal(itemsAtom()[key])
|
||||
end
|
||||
|
||||
itemsAtom({ [1] = "d", [3] = "f" })
|
||||
expect(itemAtoms()[2]).to.never.be.ok()
|
||||
expect(updates).to.equal(2)
|
||||
end)
|
||||
|
||||
it("has writable items", function()
|
||||
local itemsAtom = atom({ "a", "b", "c" })
|
||||
local itemAtoms = split(itemsAtom)
|
||||
local updates = 0
|
||||
|
||||
itemAtoms.listeners[function()
|
||||
updates += 1
|
||||
end] = true
|
||||
|
||||
itemAtoms()[2]("x")
|
||||
expect(updates).to.equal(1)
|
||||
expect(itemsAtom()[2]).to.equal("x")
|
||||
expect(itemAtoms()[2]()).to.equal("x")
|
||||
end)
|
||||
|
||||
it("uses key extractor", function()
|
||||
local itemsAtom = atom({ { key = 1 }, { key = 2 }, { key = 3 } })
|
||||
local itemAtoms = split(itemsAtom, function(item)
|
||||
return item.key
|
||||
end)
|
||||
|
||||
for key = 1, 3 do
|
||||
local itemAtom = itemAtoms()[key]
|
||||
expect(itemAtom()).to.equal(itemsAtom()[key])
|
||||
end
|
||||
|
||||
local prevAtoms = itemAtoms()
|
||||
itemsAtom({ itemsAtom()[1], itemsAtom()[3] })
|
||||
|
||||
expect(itemAtoms()[1]).to.equal(prevAtoms[1])
|
||||
expect(itemAtoms()[1]()).to.equal(itemsAtom()[1])
|
||||
expect(itemAtoms()[2]).to.never.be.ok()
|
||||
expect(itemAtoms()[3]).to.equal(prevAtoms[3])
|
||||
expect(itemAtoms()[3]()).to.equal(itemsAtom()[2])
|
||||
end)
|
||||
|
||||
it("allows observe()", function()
|
||||
local itemsAtom = atom({
|
||||
{ key = 1, value = "a" },
|
||||
{ key = 2, value = "b" },
|
||||
{ key = 3, value = "c" },
|
||||
})
|
||||
local itemAtoms = split(itemsAtom, function(item)
|
||||
return item.key
|
||||
end)
|
||||
|
||||
local added = 0
|
||||
local updated = 0
|
||||
local deleted = 0
|
||||
|
||||
local _, flush = observe(itemAtoms, function(item, key)
|
||||
expect(item()).to.equal(itemsAtom()[key])
|
||||
|
||||
item.listeners[function(value)
|
||||
expect(value).to.equal(itemsAtom()[key])
|
||||
updated += 1
|
||||
end] = true
|
||||
|
||||
added += 1
|
||||
|
||||
return function()
|
||||
expect(itemAtoms()[key]).to.never.be.ok()
|
||||
deleted += 1
|
||||
end
|
||||
end)
|
||||
|
||||
expect(added).to.equal(3)
|
||||
expect(updated).to.equal(0)
|
||||
expect(deleted).to.equal(0)
|
||||
|
||||
itemsAtom({ itemsAtom()[1], itemsAtom()[3] })
|
||||
flush()
|
||||
|
||||
expect(added).to.equal(3)
|
||||
expect(updated).to.equal(0)
|
||||
expect(deleted).to.equal(1)
|
||||
|
||||
itemAtoms()[1]({ key = 1, value = "x" })
|
||||
flush()
|
||||
|
||||
expect(added).to.equal(3)
|
||||
expect(updated).to.equal(1)
|
||||
expect(deleted).to.equal(1)
|
||||
|
||||
itemsAtom({})
|
||||
flush()
|
||||
|
||||
expect(added).to.equal(3)
|
||||
expect(updated).to.equal(1)
|
||||
expect(deleted).to.equal(3)
|
||||
end)
|
||||
end
|
||||
92
src/__tests__/subscribe.spec.luau
Normal file
92
src/__tests__/subscribe.spec.luau
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
local atom = require(script.Parent.Parent.atom)
|
||||
local derive = require(script.Parent.Parent.derive)
|
||||
local subscribe = require(script.Parent.Parent.subscribe)
|
||||
|
||||
return function()
|
||||
it("runs on state change", function()
|
||||
local current, previous
|
||||
local source = atom(0)
|
||||
local unsubscribe, flush = subscribe(source, function(...)
|
||||
current, previous = ...
|
||||
end)
|
||||
|
||||
for index = 1, 3 do
|
||||
source(index)
|
||||
flush()
|
||||
expect(current).to.equal(index)
|
||||
expect(previous).to.equal(index - 1)
|
||||
end
|
||||
|
||||
unsubscribe()
|
||||
source(4)
|
||||
flush()
|
||||
|
||||
expect(current).to.equal(3)
|
||||
expect(previous).to.equal(2)
|
||||
end)
|
||||
|
||||
it("batches updates", function()
|
||||
local current, previous
|
||||
local source = atom(0)
|
||||
local _, flush = subscribe(source, function(...)
|
||||
current, previous = ...
|
||||
end)
|
||||
|
||||
source(1)
|
||||
source(2)
|
||||
flush()
|
||||
|
||||
expect(current).to.equal(2)
|
||||
expect(previous).to.equal(0)
|
||||
end)
|
||||
|
||||
it("checks equality", function()
|
||||
local current, previous
|
||||
local source = atom(0):memo(function(previous, current)
|
||||
return current < previous
|
||||
end)
|
||||
local _, flush = subscribe(source, function(...)
|
||||
current, previous = ...
|
||||
end)
|
||||
|
||||
source(1)
|
||||
flush()
|
||||
|
||||
expect(current).to.equal(1)
|
||||
expect(previous).to.equal(0)
|
||||
|
||||
source(0)
|
||||
flush()
|
||||
|
||||
expect(current).to.equal(1)
|
||||
expect(previous).to.equal(0)
|
||||
end)
|
||||
|
||||
it("prevents garbage collect", function()
|
||||
local source = atom(1)
|
||||
local updates = 0
|
||||
|
||||
for _ = 1, 1000 do
|
||||
-- The idea here is to test whether subscribe() prevents every derived
|
||||
-- atom in a chain from being garbage collected. If any are GCed, then
|
||||
-- the subscription will stop updating and the test will fail.
|
||||
subscribe(
|
||||
derive(
|
||||
derive(source, function(x)
|
||||
return x
|
||||
end),
|
||||
function(x)
|
||||
return 2 * x
|
||||
end
|
||||
),
|
||||
function()
|
||||
updates += 1
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
source(source() + 1)
|
||||
task.wait()
|
||||
expect(updates).to.equal(1000)
|
||||
end)
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue