mirror of
https://github.com/littensy/charm.git
synced 2026-10-24 06:51:37 +00:00
refactor!: migrate to monorepo (#23)
* refactor!: create monorepo * chore: prepare release, set up workflows * refactor: rename `modules` to `src` * docs: add docs for all packages * refactor: rename root project file * chore: bump deps * build: rename `lint`, add `pack` script * revert: delete pack script This will not be useful for development.
This commit is contained in:
parent
b038bf1f70
commit
08c7e690d5
81 changed files with 917 additions and 570 deletions
62
tests/charm/atom.spec.luau
Normal file
62
tests/charm/atom.spec.luau
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local atom = Charm.atom
|
||||
local store = require(ReplicatedStorage.DevPackages.Charm.store)
|
||||
|
||||
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()
|
||||
value = source()
|
||||
end
|
||||
store.listeners[source][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
|
||||
store.listeners[source][listener] = true
|
||||
end
|
||||
store.listeners[source][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("takes a custom comparator", function()
|
||||
local max = atom(1, {
|
||||
equals = function(prev, next)
|
||||
return next < prev
|
||||
end,
|
||||
})
|
||||
max(2)
|
||||
expect(max()).to.equal(2)
|
||||
max(1)
|
||||
expect(max()).to.equal(2)
|
||||
max(3)
|
||||
expect(max()).to.equal(3)
|
||||
end)
|
||||
end
|
||||
64
tests/charm/batch.spec.luau
Normal file
64
tests/charm/batch.spec.luau
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local atom = Charm.atom
|
||||
local computed = Charm.computed
|
||||
local subscribe = Charm.subscribe
|
||||
local effect = Charm.effect
|
||||
local store = require(ReplicatedStorage.DevPackages.Charm.store)
|
||||
|
||||
return function()
|
||||
it("batches updates", function()
|
||||
local source = atom(1)
|
||||
local value = 0
|
||||
subscribe(source, function()
|
||||
value += 1
|
||||
end)
|
||||
store.batch(function()
|
||||
source(2)
|
||||
source(3)
|
||||
source(4)
|
||||
end)
|
||||
expect(value).to.equal(1)
|
||||
end)
|
||||
|
||||
it("handles duplicate listeners", function()
|
||||
local a = atom(1)
|
||||
local b = atom(1)
|
||||
local value = 0
|
||||
effect(function()
|
||||
value += 1
|
||||
a()
|
||||
b()
|
||||
end)
|
||||
value = 0 -- effect() will run once initially
|
||||
store.batch(function()
|
||||
a(2)
|
||||
b(2)
|
||||
a(3)
|
||||
end)
|
||||
expect(value).to.equal(1)
|
||||
end)
|
||||
|
||||
it("batches computed atoms", function()
|
||||
local source = atom(1)
|
||||
local updates = 0
|
||||
local computations = 0
|
||||
local double = computed(function()
|
||||
computations += 1
|
||||
return source() * 2
|
||||
end)
|
||||
computations = 0 -- computed atoms will run once initially
|
||||
subscribe(double, function()
|
||||
updates += 1
|
||||
end)
|
||||
store.batch(function()
|
||||
source(2)
|
||||
source(3)
|
||||
source(4)
|
||||
end)
|
||||
expect(updates).to.equal(1)
|
||||
expect(computations).to.equal(1)
|
||||
expect(double()).to.equal(8)
|
||||
end)
|
||||
end
|
||||
64
tests/charm/capture.spec.luau
Normal file
64
tests/charm/capture.spec.luau
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local atom = Charm.atom
|
||||
local subscribe = Charm.subscribe
|
||||
local store = require(ReplicatedStorage.DevPackages.Charm.store)
|
||||
|
||||
return function()
|
||||
it("gracefully handles yielding", function()
|
||||
expect(function()
|
||||
store.capture(function()
|
||||
task.wait(10)
|
||||
return 1
|
||||
end)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("captures atoms from a function", function()
|
||||
local a, b, c = atom(1), atom(1), atom(2)
|
||||
local deps, result = store.capture(function()
|
||||
return a() + b()
|
||||
end)
|
||||
expect(deps[a]).to.be.ok()
|
||||
expect(deps[b]).to.be.ok()
|
||||
expect(deps[c]).to.never.be.ok()
|
||||
expect(result).to.equal(2)
|
||||
end)
|
||||
|
||||
it("captures an atom by itself", function()
|
||||
local a = atom(1)
|
||||
local deps, result = store.capture(a)
|
||||
expect(deps[a]).to.be.ok()
|
||||
expect(result).to.equal(1)
|
||||
end)
|
||||
|
||||
it("skips atoms in inner capture calls", function()
|
||||
local a = atom(1)
|
||||
local b = atom(1)
|
||||
local inner
|
||||
local outer = store.capture(function()
|
||||
inner = store.capture(function()
|
||||
return b()
|
||||
end)
|
||||
return a()
|
||||
end)
|
||||
|
||||
assert(outer[a], "outer did not capture a")
|
||||
assert(outer[b] == nil, "outer captured b")
|
||||
assert(inner[a] == nil, "inner captured a")
|
||||
assert(inner[b], "inner did not capture b")
|
||||
end)
|
||||
|
||||
it("should not capture notified dependents", function()
|
||||
local state = atom(1)
|
||||
|
||||
subscribe(state, function() end)
|
||||
|
||||
local deps = store.capture(function()
|
||||
return state(2)
|
||||
end)
|
||||
|
||||
expect(deps[state]).to.never.be.ok()
|
||||
end)
|
||||
end
|
||||
105
tests/charm/computed.spec.luau
Normal file
105
tests/charm/computed.spec.luau
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local atom = Charm.atom
|
||||
local computed = Charm.computed
|
||||
local store = require(ReplicatedStorage.DevPackages.Charm.store)
|
||||
local collect = require(script.Parent.Parent.utils.collect)
|
||||
local count = require(script.Parent.Parent.utils.count)
|
||||
|
||||
return function()
|
||||
it("memoizes the result", function()
|
||||
local calls = 0
|
||||
local a = atom(1)
|
||||
local b = atom(2)
|
||||
local double = computed(function()
|
||||
calls += 1
|
||||
return a() * b()
|
||||
end)
|
||||
expect(calls).to.equal(1)
|
||||
a(1)
|
||||
expect(calls).to.equal(1)
|
||||
a(2)
|
||||
expect(calls).to.equal(2)
|
||||
b(3)
|
||||
expect(calls).to.equal(3)
|
||||
double()
|
||||
expect(calls).to.equal(3)
|
||||
end)
|
||||
|
||||
it("can be nested", function()
|
||||
local source = atom(1)
|
||||
local double = computed(function()
|
||||
return source() * 2
|
||||
end)
|
||||
local quadruple = computed(function()
|
||||
return double() * 2
|
||||
end)
|
||||
expect(quadruple()).to.equal(4)
|
||||
source(2)
|
||||
expect(quadruple()).to.equal(8)
|
||||
end)
|
||||
|
||||
it("collects computed atoms", function()
|
||||
local source = atom(0)
|
||||
|
||||
computed(function()
|
||||
return source()
|
||||
end)
|
||||
|
||||
collect()
|
||||
|
||||
assert(count(store.listeners[source]) == 0, "computed atom did not gc")
|
||||
end)
|
||||
|
||||
it("collects nested computed atoms", function()
|
||||
local source = atom(0)
|
||||
|
||||
do
|
||||
-- Declare this in another scope to ensure no references exist at the
|
||||
-- time of garbage collection.
|
||||
local double = computed(function()
|
||||
return source() * 2
|
||||
end)
|
||||
|
||||
computed(function()
|
||||
return double() * 2
|
||||
end)
|
||||
end
|
||||
|
||||
collect()
|
||||
|
||||
assert(count(store.listeners[source]) == 0, "nested computed atom did not gc")
|
||||
end)
|
||||
|
||||
it("tracks conditional dependencies", function()
|
||||
local condition = atom(false)
|
||||
local a = atom(0)
|
||||
local b = atom(1)
|
||||
local reruns = 0
|
||||
local current
|
||||
|
||||
-- assign variable to avoid gc
|
||||
local _ = computed(function()
|
||||
reruns += 1
|
||||
current = if condition() then a() else b()
|
||||
return current
|
||||
end)
|
||||
|
||||
expect(reruns).to.equal(1)
|
||||
expect(current).to.equal(1)
|
||||
|
||||
condition(true)
|
||||
expect(reruns).to.equal(2)
|
||||
expect(current).to.equal(0)
|
||||
|
||||
a(1)
|
||||
b(2)
|
||||
expect(reruns).to.equal(3)
|
||||
expect(current).to.equal(1)
|
||||
|
||||
condition(false)
|
||||
expect(reruns).to.equal(4)
|
||||
expect(current).to.equal(2)
|
||||
end)
|
||||
end
|
||||
96
tests/charm/effect.spec.luau
Normal file
96
tests/charm/effect.spec.luau
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local atom = Charm.atom
|
||||
local effect = Charm.effect
|
||||
|
||||
return function()
|
||||
it("runs on state change", function()
|
||||
local a = atom(0)
|
||||
local b = atom(0)
|
||||
local current
|
||||
local unsubscribe = effect(function()
|
||||
current = a() + b()
|
||||
end)
|
||||
|
||||
expect(current).to.equal(0)
|
||||
|
||||
for index = 1, 3 do
|
||||
a(index)
|
||||
expect(current).to.equal(index)
|
||||
end
|
||||
|
||||
for index = 1, 3 do
|
||||
b(index)
|
||||
expect(current).to.equal(a() + index)
|
||||
end
|
||||
|
||||
unsubscribe()
|
||||
a(4)
|
||||
expect(current).to.equal(6)
|
||||
end)
|
||||
|
||||
it("accepts a cleanup function", function()
|
||||
local a = atom(0)
|
||||
local cleanups = 0
|
||||
local unsubscribe = effect(function()
|
||||
a()
|
||||
return function()
|
||||
cleanups += 1
|
||||
end
|
||||
end)
|
||||
|
||||
expect(cleanups).to.equal(0)
|
||||
a(1)
|
||||
expect(cleanups).to.equal(1)
|
||||
unsubscribe()
|
||||
expect(cleanups).to.equal(2)
|
||||
end)
|
||||
|
||||
it("tracks conditional dependencies", function()
|
||||
local condition = atom(false)
|
||||
local a = atom(0)
|
||||
local b = atom(1)
|
||||
local reruns = 0
|
||||
local current
|
||||
|
||||
effect(function()
|
||||
reruns += 1
|
||||
current = if condition() then a() else b()
|
||||
end)
|
||||
|
||||
expect(reruns).to.equal(1)
|
||||
expect(current).to.equal(1)
|
||||
|
||||
condition(true)
|
||||
expect(reruns).to.equal(2)
|
||||
expect(current).to.equal(0)
|
||||
|
||||
a(1)
|
||||
b(2)
|
||||
expect(reruns).to.equal(3)
|
||||
expect(current).to.equal(1)
|
||||
|
||||
condition(false)
|
||||
expect(reruns).to.equal(4)
|
||||
expect(current).to.equal(2)
|
||||
end)
|
||||
|
||||
it("allows self-cleanup", function()
|
||||
local a = atom(1)
|
||||
local cleanedUp = false
|
||||
local cleanup
|
||||
|
||||
cleanup = effect(function()
|
||||
if cleanup then
|
||||
assert(not cleanedUp, "cleanup ran twice")
|
||||
cleanup()
|
||||
cleanedUp = true
|
||||
end
|
||||
a()
|
||||
end)
|
||||
|
||||
a(2)
|
||||
a(3)
|
||||
end)
|
||||
end
|
||||
19
tests/charm/init.spec.luau
Normal file
19
tests/charm/init.spec.luau
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local store = require(ReplicatedStorage.DevPackages.Charm.store)
|
||||
|
||||
return function()
|
||||
beforeAll(function()
|
||||
_G.__DEV__ = true
|
||||
end)
|
||||
|
||||
afterAll(function()
|
||||
_G.__DEV__ = nil
|
||||
end)
|
||||
|
||||
afterEach(function()
|
||||
table.clear(store.listeners :: any)
|
||||
table.clear(store.capturing.stack)
|
||||
store.capturing.index = 0
|
||||
end)
|
||||
end
|
||||
97
tests/charm/mapped.spec.luau
Normal file
97
tests/charm/mapped.spec.luau
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local atom = Charm.atom
|
||||
local mapped = Charm.mapped
|
||||
local observe = Charm.observe
|
||||
local store = require(ReplicatedStorage.DevPackages.Charm.store)
|
||||
|
||||
return function()
|
||||
it("returns mapped atoms", function()
|
||||
local itemsAtom = atom({ "a", "b", "c" })
|
||||
local mappedAtom = mapped(itemsAtom, function(value, key)
|
||||
return string.upper(value), key
|
||||
end)
|
||||
local updates = 0
|
||||
|
||||
store.listeners[mappedAtom][function()
|
||||
updates += 1
|
||||
end] = true
|
||||
|
||||
for key = 1, 3 do
|
||||
local item = mappedAtom()[key]
|
||||
expect(item).to.equal(string.upper(itemsAtom()[key]))
|
||||
end
|
||||
|
||||
itemsAtom({ "d", "e", "f" })
|
||||
expect(updates).to.equal(1)
|
||||
|
||||
for key = 1, 3 do
|
||||
local item = mappedAtom()[key]
|
||||
expect(item).to.equal(string.upper(itemsAtom()[key]))
|
||||
end
|
||||
|
||||
itemsAtom({ [1] = "d", [3] = "f" })
|
||||
expect(mappedAtom()[2]).to.never.be.ok()
|
||||
expect(updates).to.equal(2)
|
||||
end)
|
||||
|
||||
it("uses key extractor", function()
|
||||
local itemsAtom = atom({ { key = 1 }, { key = 2 }, { key = 3 } })
|
||||
local mappedAtom = mapped(itemsAtom, function(item)
|
||||
return item, item.key
|
||||
end)
|
||||
|
||||
for key = 1, 3 do
|
||||
local item = mappedAtom()[key]
|
||||
expect(item).to.equal(itemsAtom()[key])
|
||||
end
|
||||
|
||||
local previous = mappedAtom()
|
||||
itemsAtom({ itemsAtom()[1], itemsAtom()[3] })
|
||||
|
||||
expect(mappedAtom()[1]).to.equal(previous[1])
|
||||
expect(mappedAtom()[1]).to.equal(itemsAtom()[1])
|
||||
expect(mappedAtom()[2]).to.never.be.ok()
|
||||
expect(mappedAtom()[3]).to.equal(previous[3])
|
||||
expect(mappedAtom()[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 mappedAtom = mapped(itemsAtom, function(item)
|
||||
return item, item.key
|
||||
end)
|
||||
|
||||
local added = 0
|
||||
local deleted = 0
|
||||
|
||||
observe(mappedAtom, function(item, key)
|
||||
expect(item).to.equal(itemsAtom()[key])
|
||||
added += 1
|
||||
|
||||
return function()
|
||||
expect(mappedAtom()[key]).to.never.be.ok()
|
||||
deleted += 1
|
||||
end
|
||||
end)
|
||||
|
||||
expect(added).to.equal(3)
|
||||
expect(deleted).to.equal(0)
|
||||
|
||||
itemsAtom({ itemsAtom()[1], itemsAtom()[3] })
|
||||
|
||||
expect(added).to.equal(3)
|
||||
expect(deleted).to.equal(1)
|
||||
|
||||
itemsAtom({})
|
||||
|
||||
expect(added).to.equal(3)
|
||||
expect(deleted).to.equal(3)
|
||||
end)
|
||||
end
|
||||
35
tests/charm/observe.spec.luau
Normal file
35
tests/charm/observe.spec.luau
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local atom = Charm.atom
|
||||
local observe = Charm.observe
|
||||
|
||||
return function()
|
||||
it("observes lifetime", function()
|
||||
local itemsAtom = atom({ "a", "b", "c" })
|
||||
local state = {}
|
||||
|
||||
local cleanup = 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()[key])
|
||||
end
|
||||
|
||||
itemsAtom({ [4] = "d", [5] = "e", [6] = "f" })
|
||||
|
||||
for key = 1, 6 do
|
||||
expect(state[key]).to.equal(itemsAtom()[key])
|
||||
end
|
||||
|
||||
cleanup()
|
||||
|
||||
for key = 1, 6 do
|
||||
expect(state[key]).to.equal(nil)
|
||||
end
|
||||
end)
|
||||
end
|
||||
26
tests/charm/peek.spec.luau
Normal file
26
tests/charm/peek.spec.luau
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local atom = Charm.atom
|
||||
local capture = Charm.capture
|
||||
local peek = Charm.peek
|
||||
|
||||
return function()
|
||||
it("calls an atom without capturing it", function()
|
||||
local a, b, c = atom(1), atom(1), atom(1)
|
||||
local deps, result = capture(function()
|
||||
return a() + peek(b) + c()
|
||||
end)
|
||||
expect(deps[a]).to.be.ok()
|
||||
expect(deps[b]).to.never.be.ok()
|
||||
expect(deps[c]).to.be.ok()
|
||||
expect(result).to.equal(3)
|
||||
end)
|
||||
|
||||
it("passes through arguments", function()
|
||||
expect(peek(1)).to.equal(1)
|
||||
expect(peek(function()
|
||||
return 1
|
||||
end)).to.equal(1)
|
||||
end)
|
||||
end
|
||||
101
tests/charm/subscribe.spec.luau
Normal file
101
tests/charm/subscribe.spec.luau
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Charm = require(ReplicatedStorage.DevPackages.Charm)
|
||||
local atom = Charm.atom
|
||||
local computed = Charm.computed
|
||||
local subscribe = Charm.subscribe
|
||||
local store = require(ReplicatedStorage.DevPackages.Charm.store)
|
||||
local collect = require(script.Parent.Parent.utils.collect)
|
||||
local count = require(script.Parent.Parent.utils.count)
|
||||
|
||||
return function()
|
||||
it("runs on state change", function()
|
||||
local current, previous
|
||||
local source = atom(0)
|
||||
local unsubscribe = subscribe(source, function(...)
|
||||
current, previous = ...
|
||||
end)
|
||||
|
||||
for index = 1, 3 do
|
||||
source(index)
|
||||
expect(current).to.equal(index)
|
||||
expect(previous).to.equal(index - 1)
|
||||
end
|
||||
|
||||
unsubscribe()
|
||||
source(4)
|
||||
|
||||
expect(current).to.equal(3)
|
||||
expect(previous).to.equal(2)
|
||||
end)
|
||||
|
||||
it("prevents garbage collect", function()
|
||||
local source = atom(1)
|
||||
local updates = 0
|
||||
|
||||
-- Create a new scope to encapsulate variables with a limited lifetime, like
|
||||
-- 'unsubscribe' and 'computed', to ensure they are garbage collected.
|
||||
--
|
||||
-- The idea here is to test whether subscribe() prevents every computed
|
||||
-- atom in a chain from being garbage collected. If any are GCed, then
|
||||
-- the subscription will stop updating and the test will fail.
|
||||
do
|
||||
local unsubscribe
|
||||
|
||||
do
|
||||
local double = computed(function()
|
||||
return source() * 2
|
||||
end)
|
||||
|
||||
unsubscribe = subscribe(
|
||||
computed(function()
|
||||
return double() * 2
|
||||
end),
|
||||
function()
|
||||
updates += 1
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
collect()
|
||||
source(source() + 1)
|
||||
|
||||
assert(updates == 1, "subscription stopped updating")
|
||||
assert(count(store.listeners[source]) == 1, "computed atom was garbage collected")
|
||||
|
||||
unsubscribe()
|
||||
end
|
||||
|
||||
collect()
|
||||
|
||||
assert(count(store.listeners[source]) == 0, "computed atom did not collect")
|
||||
end)
|
||||
|
||||
it("tracks conditional dependencies", function()
|
||||
local condition = atom(false)
|
||||
local a = atom(0)
|
||||
local b = atom(1)
|
||||
local reruns = 0
|
||||
local current
|
||||
|
||||
subscribe(function()
|
||||
return if condition() then a() else b()
|
||||
end, function(state)
|
||||
reruns += 1
|
||||
current = state
|
||||
end)
|
||||
|
||||
condition(true)
|
||||
expect(reruns).to.equal(1)
|
||||
expect(current).to.equal(0)
|
||||
|
||||
a(1)
|
||||
b(2)
|
||||
expect(reruns).to.equal(2)
|
||||
expect(current).to.equal(1)
|
||||
|
||||
condition(false)
|
||||
expect(reruns).to.equal(3)
|
||||
expect(current).to.equal(2)
|
||||
end)
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue